Skip to main content
[AVAILABLE], packages/api/src/routes/webhooks-razorpay.ts, packages/api/src/webhooks/verifyRazorpayWebhookSignature.ts. Route receipt and durable dedup are proven, see the scope note in Razorpay for what’s proven where.

The route

POST /webhooks/razorpay only exists if RAZORPAY_WEBHOOK_SECRET is configured, unset in production means the route is never mounted and returns 404, mirroring how the connector itself is absent when its own credentials are unset.

Order of operations, and why it’s in this order

The handler does exactly four things, strictly in this order, and the order is deliberate:
  1. Verify the signature. Side-effect-free, nothing is written yet.
  2. Only then read the event id header.
  3. Only then parse the payload.
  4. Only then record the event, the one side-effecting step.
A forged request, one that fails step 1, can never reach step 4 and consume a legitimate event id. Rejected-signature audit events carry no payload-derived fields at all, because the payload is never parsed before the signature passes.

Verifying the signature yourself

HMAC-SHA256 over the raw request body bytes, hex-encoded, compared in constant time against the X-Razorpay-Signature header. This matches Razorpay’s own documented webhook signature scheme.
You must verify against the exact original bytes, not a parsed and re-serialized copy. This is proven directly: packages/api/tests/unit/webhooks/verify-razorpay-webhook-signature.test.ts verifies a signature against the real raw body, then verifies the same logical payload again after JSON.stringify(JSON.parse(rawBody)), and that second check fails. Key ordering, whitespace, and number formatting can all change during a parse-then-stringify round trip even though the JSON is “the same.” This is exactly why the route mounts express.raw({ type: "application/json" }) for this path specifically, instead of the app’s global express.json(), req.body here is a Buffer of the exact wire bytes, never a reconstructed object.
A working example, taken directly from the unit test’s fixture:

Required headers

The body is capped at 1MB, over that limit returns 413 {"error":"Payload too large."}.

Responses

A duplicate delivery still returns 200, Razorpay’s retry behavior expects an acknowledgement, not an error, for an event it already successfully delivered. It is simply never reprocessed.

How duplicate deliveries are actually prevented

RazorpayWebhookEventStore.recordIfUnseen() is a single atomic call that both checks whether an event id has been seen and persists it, there is no separate check-then-set, so there is no race window between two near-simultaneous deliveries of the same event. In production this is backed by Supabase, an INSERT into razorpay_webhook_events whose primary key on event_id is the entire atomicity mechanism. It is never in-memory outside NODE_ENV=test, see Production deployment. This dedups deliveries. It does not dedup processing, that’s a second, independent check inside the settlement processor, see Settlement.

A real captured payload

packages/api/tests/fixtures/razorpay-webhook-real-delivery.ts is an actual Razorpay-delivered payload, redacted for PII, not a synthetic test fixture. If you’re writing your own test payloads, note the shape it proves: payload.payment and payload.refund are siblings under payload, not nested inside each other.
The route’s own request schema (schemas/requests/razorpay-webhook-event.schema.json) allows additional properties beyond what’s shown here, the full Razorpay envelope is stored verbatim. The schema documents shape for reference, it is not what authenticates a request, the signature is.

Local testing without a real Razorpay account

Start the server with NODE_ENV=test (see Quickstart step 3), which mounts the webhook route using RAZORPAY_WEBHOOK_TEST_MODE_PLACEHOLDER_SECRET, razorpay-webhook-test-placeholder-secret, unless RAZORPAY_TEST_WEBHOOK_SECRET is set. Compute a signature with the same HMAC-SHA256 scheme and send it yourself, copy-paste runnable exactly as written, confirmed against a live local server:

Next

Settlement

What happens after a webhook is durably recorded.

Razorpay

The full refund connector this webhook closes the loop for.