Webhooks
Billing — Webhooks
The payment gateway is Paymob (paymob.com). Paymob posts server-to-server events
(a charge captured/failed, a card saved) to a single endpoint. This is why the
frontend must poll /usage after checkout — the real state change lands here,
not on the browser redirect.
Endpoint & signature validation
| Route | Verification |
|---|---|
POST /api/v1/webhooks/paymob | hmac (query param on transaction/card-token callbacks, body on subscription callbacks) — HMAC-SHA512 over Paymob's ordered field concatenation, compared with timingSafeEqual |
The route is public (@SkipUserJwt, @SkipAdminJwt, @SkipPermissions,
@SkipTransform) and authenticated by PaymobWebhookGuard → verifyWebhookSignature.
Paymob uses three HMAC schemes, all SHA-512 with PAYMOB_HMAC_SECRET, dispatched
by callback type:
- Transaction Processed — 20 fields from
obj(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), concatenated in that exact order, HMAC in the?hmac=query param. - Card Token — 8 fields (
card_subtype,created_at,email,id,masked_pan,merchant_id,order_id,token), HMAC in the query param. - Subscription — the string
"{trigger_type}for{subscription_data.id}", HMAC in the request body.
A bad signature throws 401. In NODE_ENV != production with no
PAYMOB_HMAC_SECRET set, verification is skipped (and warns) — never disable it
in production.
Normalized events
PaymobGatewayService.normalizeWebhookEvent maps Paymob's raw payload to one of four
event types; the charge is disambiguated by metadata.kind (ChargeKind), recovered
from the special_reference echoed as merchant_order_id (and the echoed extras):
eventType | Fires when | kind values |
|---|---|---|
charge_captured | A successful, non-pending transaction (success: true, is_auth: false) — including native-subscription auto-debits (correlated by the callback's subscription_id) | initial · renewal · seat_proration · addon_one_time · plan_change |
charge_failed | A terminal, non-pending, non-successful transaction | as above |
card_saved | A Card Token callback (or a successful is_auth validation) — the token is stored as the saved card | — |
ignored | A pending/intermediate transaction, or a Subscription lifecycle callback (money movement is confirmed by the paired transaction callback) | — |
Routing
POST /webhooks/paymob ──▶ PaymobWebhookGuard (verify HMAC)
──▶ gateway.normalizeWebhookEvent(rawBody)
──▶ SubscriptionService.processWebhookEvent(event)
· charge_captured → activate / advance period / apply plan change / credit pack
· charge_failed → past_due (read-only)
· card_saved → store card, extend trial to 30d
──▶ if now suspended → enqueue `suspend-workspace`
if active + charge_captured → enqueue `unsuspend-workspace`Each handled event writes a row to payment_events; the unique
(gateway, gateway_event_id) constraint makes a retried webhook a no-op, so
handlers are idempotent (Paymob retries).
These are Paymob → Corteksa webhooks. To receive Corteksa → your app record/object events, see the platform webhook feature — not this endpoint.