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
| Property | Value |
|---|---|
| Scheme | HMAC-SHA256 over the raw request body, hex-encoded |
| Header | x-razorpay-signature |
| Secret source | Admin 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 |
| Comparison | Constant-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
| Name | Required | Notes |
|---|---|---|
x-razorpay-signature | yes | Hex 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
}| Field | Type | Meaning |
|---|---|---|
accepted | boolean | Always true once signature is valid — the ack Razorpay needs to stop retrying |
event | string | Echo of payload.event, or "unknown" if the body shape failed the lenient schema check |
handled | boolean | true when the event drove a state change; false when deliberately ignored (unhandled event type, missing internal order id, body schema rejected) |
Event handling
| Razorpay event | Action | Side effects |
|---|---|---|
payment.captured | RazorpayPaymentService.reconcileSuccess → OrderService.markPaidByPaymentEvent | Flips 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.authorized | Ignored — manual-capture flow disabled. Acked 200 with handled: false | — |
payment.failed | RazorpayPaymentService.reconcileFailure → OrderService.markFailedByPaymentEvent | Writes 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 else | Logged 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=paidand returns 200 withhandled: 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
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | req.rawBody is missing (Fastify content-type parser not registered — see apps/api/src/main.ts) |
| 401 | UNAUTHORIZED | HMAC signature header missing, malformed, or mismatched against the configured webhook_secret |
| 500 | INTERNAL_SERVER_ERROR | payment.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.
Related
- /Users/ashik/Codes/superlabs/supercommerce/docs/payment-razorpay.md — full Razorpay provider doc: place-order integration, customer-side verify endpoint, settings keys.
- /Users/ashik/Codes/superlabs/supercommerce/docs/order.md —
OrderService.markPaidByPaymentEvent/markFailedByPaymentEventsemantics;order.paidevent; theorder_paymentrow that pins razorpay_order_id to the order at place-time.
Razorpay Magic Checkout — Webhook & Callbacks
Server-side surface for Razorpay Magic Checkout: the completion webhook (prepaid payment.captured / COD payment.pending) plus the three modal callbacks (get/apply promotions, shipping serviceability), all HMAC-verified over the raw body.
Webhook — ClickPost (Shipping)
Public HTTP surface that ClickPost's servers call to deliver shipment tracking events. The route is vendor-scoped (/:vendorId) so each vendor configures their own ClickPost…