Skip to content

Webhooks API ​

Payment, eSIM, and notification webhook endpoints use provider-specific verification before handling asynchronous reports.

The payment callbacks do not require JWT authentication. The Moyasar callback authenticates the body secret_token against the webhook secret saved for the selected environment in Admin → Plugins → Payments; the Paymob callback authenticates its hmac query parameter against the saved HMAC secret. Both then verify the payment server-side. Notification callbacks use their own adapter verification below.

MobiMatter callbacks are also public and use MobiMatter's documented RSA-SHA256 signature plus a comparison against the encrypted configured merchant ID.

Notification Callbacks ​

POST /webhooks/notifications/:channel/:provider uses operation ID receiveNotificationProviderCallback. No JWT is required. Authentication comes from the provider's raw-body signature verification.

ParameterContract
channelemail or sms
providerLowercase slug of 1 to 64 characters, matching ^[a-z0-9][a-z0-9_-]*$
X-Mock-SignatureMust equal mock for the mock callback adapter

Only mock email and SMS adapters currently implement both callback parsing and verification. Mock callbacks are disabled in production and return 404. SMTP has no callback support, so its email outcome ends at sent. Notification callbacks introduce no signing-secret configuration key.

Request and Verification ​

  1. Validate the channel and provider, then require both callback capabilities.
  2. Verify the signature over raw text before parsing JSON. A verifier failure or exception returns 401.
  3. Parse the verified body through the selected adapter. Unsupported input returns 200 with ignored: true.
  4. Persist only normalized receipt fields and enqueue the receipt ID for asynchronous processing.
  5. Stamp the confirmed handoff, then return 200.

The mock body uses the following fields:

FieldRequiredContract
idYesNonblank external event ID
messageIdYesNonblank provider message ID or null
statusYesNonblank string. sent and delivered map directly. failed and failed_terminal map to terminal failure. Other values normalize to ignored
reasonNoString or null
atNoParseable timestamp string or null
json
{
  "id": "event-1",
  "messageId": "message-1",
  "status": "delivered",
  "at": "2026-09-10T12:00:00Z"
}

Malformed JSON and invalid mock fields return { "received": true, "ignored": true } after successful signature verification. An otherwise valid unknown status is persisted as an ignored receipt instead.

Receipt and Retry Behavior ​

Receipts deduplicate on (channel, provider, externalEventId). They contain the normalized event ID, type, message ID, status, reason, and provider timestamp, not the raw body or recipient address. Queue payloads contain only the receipt ID.

200 BodyMeaning
{ "received": true }Receipt persisted and handed to the orchestration queue
{ "received": true, "duplicate": true }The same external event already has a confirmed handoff
{ "received": true, "ignored": true }The parser found no supported receipt to persist

An accepted callback is not confirmation that normalization has completed. Redelivering the same event repairs a receipt with no confirmed enqueue stamp. A receipt that already carries a stamp is answered as a duplicate and is not requeued by the request, so a redelivery cannot revive a lost job. The handoff sweep covers that case instead. Persistence, enqueue, or stamp failures return 503 with Retry-After: 60.

Normalization correlates a delivery by channel, provider, and provider message ID. It advances outcomes only forward and preserves terminal results. Provider timestamps are used when supplied, otherwise receipt time is used.

An unmatched receipt stays unprocessed while its job retries. Callback jobs use nine attempts with a 10-second exponential base, spanning 42 minutes 30 seconds in backoff. Once an unmatched receipt is at least 30 minutes old under the defaults, it is marked processed without changing a delivery. The receipt is kept. A receipt that is still unprocessed and whose enqueue stamp is older than the sweep's stale window is queued again and re-stamped by handoff reconciliation, which covers a lost job and one that used up its attempts. Callback jobs are removed from Redis on completion or failure.

The HTTP limiter applies to this route even with valid browser credentials. Counters are in memory per API process, with a default budget of 100 requests per 15 minutes per IP. This is separate from delivery queue limits and is not a shared cross-process budget.

Notification Callback Errors ​

CodeStatusCondition
NOTIFICATION_WEBHOOK_SIGNATURE_INVALID401Missing or invalid provider signature
NOTIFICATION_WEBHOOK_PROVIDER_UNKNOWN404Unknown channel or provider, missing callback capabilities, or mock callbacks in production
RATE_LIMIT_EXCEEDED429HTTP request budget exceeded, with Retry-After until reset
NOTIFICATION_WEBHOOK_UNAVAILABLE503Receipt or handoff could not be completed, with Retry-After: 60

See Notifications API for outcomes, batch reports, and delivery limits.

POST /webhooks/mobimatter ​

Accepts delayed/KYC order completion callbacks. The handler validates the payload, verifies the signature over orderId.merchantId.providerName, stores a minimal idempotency receipt, and enqueues GET-only reconciliation. It never persists the callback's QR, LPA, or other activation credentials.

Duplicate callbacks return 200. An unknown but valid signed order is acknowledged and retained as a minimal receipt. If the deterministic queue handoff fails, the endpoint returns 503 so MobiMatter can redeliver within its documented retry window.

See MobiMatter eSIM Provider Operations for deployment and recovery details.


POST /webhooks/moyasar ​

In Moyasar's test/live dashboard, register https://YOUR_PUBLIC_API/webhooks/moyasar using POST. Set Secret Token to the exact webhook secret saved for that environment in Admin → Plugins → Payments. It is separate from the API secret key. Subscribe to payment_paid, payment_captured, payment_failed and payment_voided.

json
{"id":"44444444-4444-4444-8444-444444444444","type":"payment_paid","secret_token":"EXAMPLE-WEBHOOK-SECRET","live":false,"data":{"id":"22222222-2222-4222-8222-222222222222"}}

A pre-authentication limit of 3,000 requests per minute per transport peer IP runs before body parsing or authentication. X-Forwarded-For is ignored; requests without a known transport peer IP share one unknown-peer bucket. The handler then validates the schema, selects the environment from live and authenticates the secret using constant-time comparisons against the current secret and non-revoked historical secrets. It then applies the existing limit of 1,000 authenticated requests per minute per environment. Both limits are per-process, not shared across replicas. Malformed input returns 400 MOYASAR_WEBHOOK_INVALID; invalid or revoked secrets return 401 MOYASAR_WEBHOOK_UNAUTHORIZED.

Supported events receive 200 {"received":true} only after durable BullMQ handoff to the payment-reconciliation queue is confirmed. The handoff has a 1,500 ms deadline and uses a deterministic job ID derived from provider, environment and event ID. Queue unavailability, timeout or unconfirmed handoff returns 503 MOYASAR_WEBHOOK_QUEUE_UNAVAILABLE with Retry-After: 1 for redelivery. Jobs contain only payment ID, environment, event ID and event type; secrets and raw provider payloads are not persisted in the queue or logged.

Unsupported events are ignored synchronously with 200 {"received":true,"ignored":true}. Unknown payments from other integrations are ignored asynchronously by the worker; their webhook response is the normal 200 {"received":true} acknowledgement, without ignored.

The payment reconciliation worker must be started for queued events to be processed. It checks the stored payment environment and fetches authoritative payment details from Moyasar; event amounts and redirect status are never trusted. Environment mismatches (PAYMENT_ENVIRONMENT_MISMATCH), provider verification errors and database reconciliation failures happen asynchronously and use bounded retries with exponential backoff, rather than changing the webhook acknowledgement. Authenticated POST /payments/:id/confirm remains synchronous.

If worker startup only partially succeeds, the HTTP service remains available in a degraded state and the webhook queue producer remains open. Supported events can still receive the normal acknowledgement when durable handoff succeeds, but buffered events require worker restart/recovery before they can be processed; an acknowledgement does not prove that a worker is running or reconciliation has completed. Queue handoff failures still follow the exact 503 and redelivery contract above. Shutdown attempts best-effort producer cleanup, including after partial worker startup.

Queue diagnostics emit enqueue_failed with reason not_ready, timeout, receipt_failed, receipt_unconfirmed or redis_error. The route emits webhook.queue.enqueue.failed with the generic reason handoff_failed; startup diagnostics use fixed, redacted strings. Keep diagnostic context minimal and sanitized: never include webhook tokens, API keys, decrypted credentials, raw provider payloads or raw provider error bodies. Distinguish transport/handoff failures requiring redelivery from asynchronous worker failures requiring retry or recovery.

Queue deduplication lasts while jobs are retained: up to 500 completed jobs, and failed jobs for up to seven days with a cap of 5,000. Redelivery of a retained failed job reopens a bounded retry cycle with its attempt counter reset; handoff must be confirmed before acknowledgement. This is not permanent event deduplication.

Amount, metadata or network mismatches do not settle. Processing rows become failed and minimized reconciliation records are written atomically in payment_webhook_events. Completed rows are not downgraded by mismatches. Recording failure raises PAYMENT_MISMATCH_RECORD_FAILED for worker retry; synchronous confirmation can return 500 PAYMENT_MISMATCH_RECORD_FAILED. Deterministic settlement jobs support duplicate/retry recovery.

Sandbox records a test receipt and leaves the order unpaid. Switching providers or disabling new payments does not block reconciliation of existing payments. All prior encrypted API credential generations are retained indefinitely, with no automatic pruning; original-generation lookup for in-flight payments and original retained-credential lookup for legacy unversioned payments are preserved. Retention cannot rescue API keys revoked by the gateway. Credential versions are internal, not public API fields.

Old webhook secrets remain accepted until explicitly revoked. A super_admin can send revokePreviousWebhookSecrets: true to PATCH /admin/plugins/payment/moyasar (or the deprecated PATCH /admin/plugins/moyasar) to atomically revoke all historical webhook secrets in the selected environment, including the outgoing profile if that update rotates credentials. Omitting the flag leaves revocation behavior unchanged; provider: "mock" rejects the flag. A standalone flag leaves the current token valid. To invalidate a currently leaked token, send a different webhookSecret with the flag in the same update and update the Moyasar dashboard token to match. Historical revocation remains sticky across later updates and rotations. Revoked history is excluded from webhook authentication, while API credential decryption and original/in-flight/legacy lookup remain available; provider-side API key revocation is not undone. See the safe request example.

Local testing requires a public HTTPS tunnel. The admin connection probe checks saved credentials only; a successful sandbox probe does not test webhook delivery or end-to-end payment and does not prove that a real payment has been verified. Stripe webhooks and Stripe CLI forwarding are retired.

See payments, Moyasar webhook reference and available events.


POST /webhooks/paymob ​

Paymob's transaction processed callback, sent as POST /webhooks/paymob?hmac=<128 hex characters>. Each intention sends this address as notification_url, on the API address the customer's request reached (or PUBLIC_API_URL), so nothing needs setting in the Paymob dashboard while the API is public. When the environment has an HMAC secret saved, hmac is required and must match; without one, callbacks are accepted unsigned.

The path is public and exempt from the global limiter. Like Moyasar, it has its own pre-authentication limit of 3,000 requests per minute per transport peer and a post-authentication limit of 1,000 requests per minute per environment.

The hmac value is the lowercase hex SHA-512 HMAC, keyed with the saved HMAC secret, over these obj values concatenated in order with no separators (booleans as true/false): amount_cents, created_at, currency, error_occured, has_parent_transaction, id, integration_id, is_3d_secure, is_auth, is_capture, is_refunded, is_standalone_payment, is_voided, order.id, owner, pending, source_data.pan, source_data.sub_type, source_data.type, success. The environment is the one whose saved integration produced the transaction; current and non-revoked historical HMAC secrets are accepted.

ResponseCondition
200 {"received":true}Signature verified and the job handed to the payment-reconciliation queue
200 {"received":true,"ignored":true}Non-TRANSACTION callbacks, other integrations and foreign orders
400 PAYMOB_WEBHOOK_INVALIDMalformed hmac or body
401 PAYMOB_WEBHOOK_UNAUTHORIZEDSignature does not match
503 PAYMOB_WEBHOOK_QUEUE_UNAVAILABLEQueue handoff failed, with Retry-After: 1

With an API key saved, the callback's status and amount are never trusted: the queued job fetches the transaction (GET /api/acceptance/transactions/{id} with a Bearer auth token) and settles through the same checks as Moyasar: amount, currency, payment/order/user IDs, card network for store orders, and environment via is_live. A sandbox payment whose credential generation has no API key (decided per payment, so payments created before a key was added still settle) has no way to ask Paymob, so the callback is settled at once from its own payload, through the same checks, and only when its order.id is the Paymob order this payment's intention created (stored server-side, never returned to the app); sandbox money never fulfills real SIM or eSIM orders, and production always needs the API key. Voids and refunds mark the payment failed. A declined card leaves it processing while its intention can still be paid (one hour plus a five-minute grace), because the customer may retry another card in the same checkout and failing it early would let a second checkout for the order be paid as well; after that window the next reconciliation (callback or POST /payments/:id/confirm) marks it failed. Pending transactions change nothing. revokePreviousWebhookSecrets: true on PATCH /admin/plugins/payment/paymob stops accepting historical HMAC secrets for that environment.

Internal documentation - Activation System