Supercommerce API Docs
Webhooks

Webhook — Razorpay (Payments)

Public HTTP surface that Razorpay's servers call to deliver payment lifecycle events for orders placed through the platform's Razorpay provider. Verifies HMAC-SHA256 against the…

Public HTTP surface that Razorpay's servers call to deliver payment lifecycle events for orders placed through the platform's Razorpay provider. Verifies HMAC-SHA256 against the raw body, resolves our internal order id from payload.payment.entity.notes.internal_order_id (stamped at place-order), and funnels the event through RazorpayPaymentService.reconcileSuccess / reconcileFailure — the same path the customer-side verify endpoint uses, so verify-then-webhook (or two webhook retries) is a no-op on the second call.

Source: api-modules/payment-razorpay/src/controllers/razorpay-webhook.controller.ts


Authentication

PropertyValue
SchemeHMAC-SHA256 over the raw request body, hex-encoded
Headerx-razorpay-signature
Secret sourceAdmin settings — group payment.razorpay, key webhook_secret (read via SettingsService.getGroup('admin', 'payment.razorpay')). Not an environment variable — configured per-tenant from the admin settings UI
ComparisonConstant-time (crypto.timingSafeEqual) after lowercasing + trimming the provided value

Verification happens against req.rawBody, populated globally by Fastify's content-type parser (see apps/api/src/main.ts). If the raw body is unavailable the endpoint fails fast with 400 BAD_REQUEST rather than silently accepting.

The webhook is public — no session, no API key. Authenticity is established solely by the HMAC. There is no IP allowlist.


Endpoints

POST /webhooks/payments/razorpay — Receive a Razorpay lifecycle event

Headers

NameRequiredNotes
x-razorpay-signatureyesHex HMAC-SHA256 of the raw body, keyed by webhook_secret

Body — Razorpay's standard webhook envelope (parsed by razorpayWebhookPayloadSchema):

{
  "event": "payment.captured",
  "account_id": "acc_...",        // optional
  "contains": ["payment"],         // optional
  "created_at": 1716230400,        // optional unix seconds
  "payload": {
    "payment": {
      "entity": {
        "id": "pay_NXabcDEF12345",
        "entity": "payment",
        "amount": 125000,          // paise (integer subunits)
        "currency": "INR",
        "status": "captured",
        "order_id": "order_NWxYz9LkPq...",
        "method": "card",
        "captured": true,
        "description": "...",
        "email": "ada@example.com",
        "contact": "+91...",
        "notes": { "internal_order_id": "01J9..." },
        "created_at": 1716230400,
        "error_code": null,
        "error_description": null
        // ...other Razorpay fields pass through
      }
    },
    "order": {
      "entity": {
        "id": "order_NWxYz9LkPq...",
        "amount": 125000,
        "currency": "INR",
        "receipt": "ORD-2026-00000123",
        "status": "paid",
        "notes": { "internal_order_id": "01J9..." }
        // ...other Razorpay fields pass through
      }
    }
  }
}

The internal order id is read from payload.payment.entity.notes.internal_order_id. Razorpay stamps this key onto the Razorpay order at place-time via RazorpayPaymentProvider.place(); when missing, the event is acked but not acted on (warn-logged). The order's receipt is the human order number (displayId, e.g. ORD-2026-00000123), set at place-time so the Razorpay dashboard matches the order — reconciliation still keys off notes.internal_order_id, not the receipt.

Money fields are integer subunits (paise) — same convention the platform uses internally, so no scaling.

Response 200

{
  "data": {
    "accepted": true,
    "event": "payment.captured",
    "handled": true
  },
  "message": "Success",
  "statusCode": 200
}
FieldTypeMeaning
acceptedbooleanAlways true once signature is valid — the ack Razorpay needs to stop retrying
eventstringEcho of payload.event, or "unknown" if the body shape failed the lenient schema check
handledbooleantrue when the event drove a state change; false when deliberately ignored (unhandled event type, missing internal order id, body schema rejected)

Event handling

Razorpay eventActionSide effects
payment.capturedRazorpayPaymentService.reconcileSuccessOrderService.markPaidByPaymentEventFlips order pending_payment → confirmed, paymentStatus → paid. Commits inventory reservation, stamps paid_at, writes audit row, emits order.paid. Defence-in-depth re-checks: razorpay_order_id must match the order_payment row written at place-time; payment.amount must equal the row's amount; payment.status must be captured or authorized
payment.authorizedIgnored — manual-capture flow disabled. Acked 200 with handled: false
payment.failedRazorpayPaymentService.reconcileFailureOrderService.markFailedByPaymentEventWrites failure payload (error_code, error_description, status) onto the order_payment row. Parent stays pending_payment so the storefront can retry until the inventory-reservation TTL closes the window
refund.*Ignored — v1 refunds are admin-driven via POST /admin/orders/:id/mark-refunded. Acked 200 with handled: false
anything elseLogged and acked. handled: false

Idempotency / retry handling

Safe to receive duplicates. There is no dedicated event-id dedup table — instead, idempotency is funneled through OrderService.markPaidByPaymentEvent / markFailedByPaymentEvent, which short-circuit when the order is already in the target terminal state. On ConflictException (already-paid / already-failed) RazorpayPaymentService catches the rejection and returns the current OrderResponse via OrderViewService.build, so the webhook still responds 200.

Concrete races handled cleanly:

  • Customer-side verify wins, then webhook arrives → webhook observes paymentStatus=paid and returns 200 with handled: true (second call is a no-op state-wise).
  • Razorpay retries after a transient 5xx (or any 2xx-but-slow response) → second delivery hits the same conflict guard and returns 200.
  • Two simultaneous deliveries → first wins via DB transaction; second sees the conflict and returns 200.

When the body shape fails the schema check, we still ack 200 (with event: "unknown", handled: false) and log the rejection server-side — Razorpay would otherwise retry forever on a payload format we can't action.


Failure modes

StatusCodeWhen
400BAD_REQUESTreq.rawBody is missing (Fastify content-type parser not registered — see apps/api/src/main.ts)
401UNAUTHORIZEDHMAC signature header missing, malformed, or mismatched against the configured webhook_secret
500INTERNAL_SERVER_ERRORpayment.razorpay.webhook_secret setting missing (RazorpayMisconfiguredError)

Note: malformed JSON or schema-rejected bodies do not raise — they return 200 with accepted: true, event: "unknown", handled: false so Razorpay stops retrying. Server log captures the schema reason.

Defence-in-depth rejections inside reconcileSuccess (razorpay_order_id does not match, amount mismatch, payment not captured) raise 400 BAD_REQUEST — Razorpay will retry these, but the underlying mismatch will not resolve itself, so persistent retries here usually indicate a misconfigured key pair or a cross-account event delivery.


On this page