Corteksa
GuidesBilling

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

RouteVerification
POST /api/v1/webhooks/paymobhmac (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 PaymobWebhookGuardverifyWebhookSignature.

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):

eventTypeFires whenkind values
charge_capturedA 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_failedA terminal, non-pending, non-successful transactionas above
card_savedA Card Token callback (or a successful is_auth validation) — the token is stored as the saved card
ignoredA 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.

On this page