> ## Documentation Index
> Fetch the complete documentation index at: https://docs.parmanasystems.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify Razorpay Webhooks

> How POST /webhooks/razorpay authenticates a delivery, why the raw body bytes matter, and how duplicate deliveries are handled.

<Info>**\[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](/integrations/razorpay) for what's proven where.</Info>

## 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

```typescript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyRazorpayWebhookSignature(
  rawBody: Buffer,
  signatureHeader: string,
  secret: string,
): boolean {
  const expected = Buffer.from(
    createHmac("sha256", secret).update(rawBody).digest("hex"),
    "hex",
  );
  const candidate = Buffer.from(signatureHeader, "hex");

  return expected.length === candidate.length && timingSafeEqual(expected, candidate);
}
```

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.

<Warning>
  **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.
</Warning>

A working example, taken directly from the unit test's fixture:

```typescript theme={null}
const body = Buffer.from(
  '{"event":"refund.processed","payload":{"refund":{"entity":{"id":"rfnd_ABC123"}}}}',
);
const secret = "unit-test-webhook-secret";
const signature = createHmac("sha256", secret).update(body).digest("hex");

verifyRazorpayWebhookSignature(body, signature, secret); // true
```

## Required headers

| Header                           | Purpose                                                        | Missing or invalid                                                              |
| -------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| `X-Razorpay-Signature`           | HMAC-SHA256 over the raw body, hex-encoded                     | `401 {"error":"Missing signature."}` or `401 {"error":"Invalid signature."}`    |
| `X-Razorpay-Event-Id`            | Checked only after the signature passes, used as the dedup key | `401 {"error":"Missing event id."}`                                             |
| `Content-Type: application/json` | Required for the raw-body parser to run                        | `400 {"error":"Expected an application/json body."}` if the body isn't a Buffer |

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

## Responses

| Condition                                             | Status | Body                                             |
| ----------------------------------------------------- | ------ | ------------------------------------------------ |
| Fresh event, recorded                                 | `200`  | `{"status":"accepted"}`                          |
| Duplicate event, already recorded                     | `200`  | `{"status":"acknowledged"}`                      |
| Rejected (bad or missing signature, missing event id) | `401`  | `{"error":"..."}`                                |
| Body not a Buffer                                     | `400`  | `{"error":"Expected an application/json body."}` |
| Body over 1MB                                         | `413`  | `{"error":"Payload too large."}`                 |

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](/deployment/production).

This dedups *deliveries*. It does not dedup *processing*, that's a second, independent check
inside the settlement processor, see [Settlement](/concepts/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.

```json theme={null}
{
  "entity": "event",
  "event": "refund.processed",
  "contains": ["refund"],
  "payload": {
    "payment": { "entity": { "id": "pay_XYZ789" } },
    "refund": {
      "entity": {
        "id": "rfnd_ABC123",
        "payment_id": "pay_XYZ789",
        "amount": 100,
        "status": "processed"
      }
    }
  },
  "created_at": 1752800000
}
```

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](/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:

```bash theme={null}
SECRET="razorpay-webhook-test-placeholder-secret"
BODY='{"event":"refund.processed","payload":{"refund":{"entity":{"id":"rfnd_test123"}}}}'
SIGNATURE=$(node -e "console.log(require('crypto').createHmac('sha256', '$SECRET').update('$BODY').digest('hex'))")

curl -X POST http://localhost:3000/webhooks/razorpay \
  -H "Content-Type: application/json" \
  -H "X-Razorpay-Signature: $SIGNATURE" \
  -H "X-Razorpay-Event-Id: evt_local_test_001" \
  -d "$BODY"
# {"status":"accepted"}
```

## Next

<CardGroup cols={2}>
  <Card title="Settlement" icon="badge-check" href="/concepts/settlement">
    What happens after a webhook is durably recorded.
  </Card>

  <Card title="Razorpay" icon="credit-card" href="/integrations/razorpay">
    The full refund connector this webhook closes the loop for.
  </Card>
</CardGroup>
