Webhooks

Webhooks are the source of truth for payment status. The hosted checkout may close before the customer's browser hits your successUrl — the webhook always fires.

Register an endpoint

Dashboard → Developers → Webhooks → Add endpoint. Save the signing secret somewhere safe; you'll use it to verify signatures.

Event types

EventWhen
payment.successTransaction captured — invoice issued if auto-issue enabled
payment.failedAuthorization declined or gateway error
payment.refundedFull refund completed — linked invoice voided
payment.partially_refundedPartial refund completed
payment.cancelledCustomer abandoned checkout
payment.requires_action3-D Secure required — customer needs to come back

Payload

json
{
  "event": "payment.success",
  "transactionId": "8f8a…",
  "amount": 1000.00,
  "currency": "SAR",
  "status": "SUCCESS",
  "billId": "a1b2c3d4-…",
  "billNumber": "INV-2026-00042",
  "totalCost": 1000.00,
  "totalVat": 130.43,
  "totalWithoutVat": 869.57
}

Bill fields (billId, billNumber, totalCost, totalVat, totalWithoutVat) are present when a VAT invoice was issued for the transaction. See Educational billing.

Verifying the signature

minnha signs every webhook with HMAC-SHA256 of the raw body, using your endpoint's secret. The signature is sent in the X-Minnha-Signature header.

Always verify the signature before trusting the payload. Unsigned webhooks are trivially spoofed.
ts
import { Minnha, MinnhaSignatureError } from "@minnha/node";

app.post(
  "/webhooks/minnha",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = Minnha.Webhooks.parse(
        req.body,                                  // RAW Buffer (not parsed)
        req.header("x-minnha-signature")!,
        process.env.MINNHA_WEBHOOK_SECRET!
      );

      switch (event.event) {
        case "payment.success":
          if (event.billNumber) {
            // link invoice INV-2026-00042 to your order
          }
          markOrderPaid(event.transactionId);
          break;
        case "payment.failed":    markOrderFailed(event.transactionId); break;
        case "payment.refunded":  refundOrder(event.transactionId); break;
      }

      res.status(200).end(); // ALWAYS respond 200 once processed
    } catch (err) {
      if (err instanceof MinnhaSignatureError) return res.status(400).send("bad signature");
      throw err;
    }
  }
);

Retries

If your endpoint returns anything other than 2xx within 5 seconds, minnha retries with exponential backoff for up to 24 hours. Deduplicate on transactionId + event.

Inspecting deliveries

Dashboard → Developers → Webhooks → Recent deliveries shows the last 100 deliveries with response codes. Click any failed delivery to Retry manually.