12 November 2026 · 5 min read
Razorpay webhooks: signature checking and idempotency explained
A webhook is a message that Razorpay sends to your server when something happens, such as a payment being captured. It is the most reliable way to learn that someone paid, because it does not depend on the buyer's browser staying open.
A webhook endpoint is a public address. Anybody can send a request to it. So two questions decide whether your endpoint is safe:
- Is this message really from Razorpay? (signature checking)
- Have I already handled this message? (idempotency)
Step 0: create the webhook
In the Razorpay dashboard, open Settings, then Webhooks, and add your endpoint address, for example https://yourdomain.com/api/webhooks/razorpay. You choose a webhook secret yourself. It is a different value from your API Key Secret. Keep it in an environment variable:
RAZORPAY_WEBHOOK_SECRET=some-long-random-text
Choose the events you need. For a simple shop these are usually payment.captured, order.paid, payment.failed and refund.processed. If you sell subscriptions, add the subscription events too. Listening to fewer events means fewer cases to get wrong.
Signature checking
Razorpay signs every request. It makes an HMAC-SHA256 fingerprint of the raw request body with your webhook secret and sends it in the X-Razorpay-Signature header. You make the same fingerprint and compare.
The most common mistake is to parse the JSON first and then re-create the text. Even a small change in spacing or key order gives a different fingerprint. In a Next.js route handler, read the body as text:
// app/api/webhooks/razorpay/route.ts
import crypto from "node:crypto";
export async function POST(req: Request) {
const rawBody = await req.text(); // the exact bytes Razorpay sent
const signature = req.headers.get("x-razorpay-signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("Invalid signature", { status: 400 });
}
const event = JSON.parse(rawBody); // only parse after the signature is good
await handleEvent(event);
return new Response("ok", { status: 200 });
}
Notes:
- Use
timingSafeEqualso the comparison does not leak information through timing. Check the lengths first, because it throws if they differ. - If the webhook secret is missing on your server, refuse the request. Do not skip the check "for now".
- Do not log the raw body or the signature. They can contain customer details, and the signature is useful to an attacker who can replay it.
Why you need idempotency
Idempotent means: doing it twice has the same result as doing it once.
Webhooks can arrive more than once. If your endpoint is slow or answers with an error, Razorpay will try again later. Your own code can also retry. Several events can arrive for one payment as well (for example both payment.captured and order.paid). If every arrival grants access, sends a receipt and creates an invoice, one payment becomes two receipts and two invoice numbers.
How to make it idempotent
Do not rely on "check, then act" in your code, because two requests can arrive at the same moment and both pass the check. Let the database decide. Two simple tools:
1. A unique constraint on the payment id.
create unique index payments_razorpay_payment_uidx on payments (razorpay_payment_id);
2. A conditional update that only the first request can win.
const result = await db.execute(sql`
update payments set status = 'paid', paid_at = now(), razorpay_payment_id = ${paymentId}
where razorpay_order_id = ${orderId} and status <> 'paid'
returning id`);
if (result.rows.length === 0) {
return new Response("ok", { status: 200 }); // already handled: say thanks and do nothing
}
// Only the request that actually changed the row gets here: send the receipt, create the invoice, grant access.
The first request changes the row and continues. Any repeat finds nothing to change and stops. Both get 200, so Razorpay stops retrying.
Razorpay also sends an event id in a header. Check the current Razorpay documentation for its exact name. You can store event ids you have seen and skip repeats. Making your own writes idempotent is still the safer base, since it protects you from every source of repeats.
Answer quickly, and with the right code
- Return
200as soon as you have safely recorded the event. Slow work, such as sending email, should not make Razorpay wait. - Return
400for a bad signature. Return5xxonly for a real failure on your side, so that Razorpay tries again. - Unknown event types: return
200and ignore them. Do not fail on events you did not ask for.
Test it
- Send yourself a real test-mode payment and confirm the order turns to "paid" once.
- Deliver the same event a second time. If the Razorpay dashboard offers a resend option for webhook deliveries, use it; if not, replay a saved request yourself. Nothing should change, and no second email should go out.
- Send a request with a wrong signature, for example with
curl. It must be refused. - Stop your server, pay, and start it again. Razorpay's retry should then complete the order.
Related reading
- How to take UPI payments in a Next.js app with Razorpay covers creating the order and the browser side.
- A launch checklist for an Indian SaaS lists where this fits before going live.
India SaaS Kit includes a signed, repeat-safe webhook and an admin page that shows every message Razorpay sent, without storing the body or the signature. See the plans.