Hardening a Razorpay integration: signatures, idempotent webhooks, and a settlement ledger
Hardening a Razorpay integration in Next.js: checkout vs webhook signature verification, idempotent settlement with a Postgres ledger, and the operational guards.
On this page
- The client callback is a hint, not a source of truth
- Signature verification: checkout and webhook are not the same recipe
- Idempotent settlement: dedupe by event id, no-op on conflict
- Live progress without trusting a slow cron
- The operational guards that make it trustworthy
- What I'd tell my past self
- FAQ
A donor on Kat-Katha's giving page taps "Donate ₹2,000", the Razorpay sheet pops up, they pay, and the sheet closes with a success callback. Easy. Then their phone loses signal for two seconds, the browser tab dies on the redirect, and the donation never lands in the ledger — even though the money left their account. That gap, between money captured by Razorpay and fulfillment recorded by your app, is the whole game.
Hardening a Razorpay integration isn't about taking the payment; that part is a few lines of SDK. It's about making the record of it correct exactly once, even when the network, the browser, and the gateway all conspire against you. This is a practical guide built from the Kat-Katha NGO giving platform and the Trendverse D2C store — two Next.js 15 sites with end-to-end Razorpay flows. The same webhook and idempotency discipline drives both a donation ledger and a checkout pipeline.
The client callback is a hint, not a source of truth
Razorpay Checkout, on success, hands your front end three values: razorpay_order_id, razorpay_payment_id, and razorpay_signature. The naive flow is: receive those in the browser, POST them to your server, mark the donation paid, show a thank-you. It works in the demo and fails in production for reasons that have nothing to do with your code:
- The user closes the tab between payment captured and callback fired.
- A flaky network drops the callback POST.
- A bad actor opens dev tools and replays a fabricated success.
So I treat the client callback as a fast, optimistic hint — good enough to render "thank you" immediately — but never the thing that writes fulfillment. The authoritative write comes from Razorpay's server-to-server webhook, which fires independently of the user's browser. The callback makes the UI feel instant; the webhook makes the ledger true. Two different signatures, two different jobs.
Signature verification: checkout and webhook are not the same recipe
This trips people up because Razorpay signs two different things with two different secrets, and using the wrong recipe silently "works" until it doesn't.
Checkout callback — verify HMAC-SHA256 of the string order_id|payment_id using your key secret, and compare timing-safe:
import { createHmac, timingSafeEqual } from "node:crypto";
// One safe-compare helper, reused everywhere.
function safeEqual(a: string, b: string) {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
// timingSafeEqual throws on length mismatch, so guard first.
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
function verifyCheckout(orderId: string, paymentId: string, sig: string) {
const expected = createHmac("sha256", process.env.RAZORPAY_KEY_SECRET!)
.update(`${orderId}|${paymentId}`)
.digest("hex");
return safeEqual(expected, sig);
}
The timingSafeEqual matters: a plain === leaks, through response timing, how many leading bytes matched — enough to forge a signature byte by byte. The length guard is there because timingSafeEqual throws on mismatched buffer lengths, so an attacker sending a short signature would otherwise crash the handler instead of being cleanly rejected.
Webhook — a completely different recipe. Razorpay sends an x-razorpay-signature header that is HMAC-SHA256 of the raw request body using your webhook secret (a separate secret you set when registering the webhook). The single most common bug here: signing JSON.stringify(parsedBody) instead of the literal bytes Razorpay sent. Re-serializing reorders keys and changes whitespace, so the HMAC never matches. You must read the raw body before any JSON parsing.
In a Next.js App Router route handler:
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes — do NOT req.json() first
const sig = req.headers.get("x-razorpay-signature") ?? "";
const expected = createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
.update(raw)
.digest("hex");
if (!safeEqual(expected, sig)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(raw); // parse only AFTER verifying
// ...settle
}
Verify first, parse second. If you parse first, you've already lost the bytes you needed to verify — and the shared safeEqual means a junk header returns a clean 400, not a 500.
Idempotent settlement: dedupe by event id, no-op on conflict
Here's the fact that dictates the whole database design: Razorpay can deliver the same webhook event more than once. Retries on timeout, at-least-once delivery, a redeploy mid-request — any of these can replay payment.captured for the same payment. If your handler naively does UPDATE campaign SET raised = raised + amount, a duplicate delivery double-counts a donation. On Trendverse, it could mark a single order paid twice or fire two dispatch emails.
The fix is to make the write idempotent at the database level rather than trying to be clever in application code. Each Razorpay event carries a unique id; I persist it under a UNIQUE constraint and let Postgres reject the duplicate:
create table donation_events (
event_id text primary key, -- Razorpay's event id
payment_id text not null,
order_id text not null,
amount integer not null, -- paise
payload jsonb not null, -- append-only audit trail
created_at timestamptz default now()
);
-- The settlement write, deduped:
insert into donation_events (event_id, payment_id, order_id, amount, payload)
values ($1, $2, $3, $4, $5)
on conflict (event_id) do nothing
returning event_id;
If the RETURNING clause yields a row, this is the first time I've seen the event, so I run fulfillment — increment the campaign total in the donations ledger, send the Resend thank-you — inside the same transaction. If it returns nothing, the event was already processed, so I no-op and return 200. Returning 200 on a duplicate is important: a non-2xx tells Razorpay to keep retrying, so a buggy "already exists → 500" turns one duplicate into an infinite retry storm.
The payload jsonb column is deliberate. It's an append-only event audit trail — every raw event we ever received, verbatim. When a donor emails "did my payment go through?", I answer from the audit log instead of guessing. The same logic that protects against overselling inventory underpins my write on a Postgres slot-locking approach for preventing double-bookings: let the database be the arbiter of "exactly once," not your application code.
Live progress without trusting a slow cron
Once the ledger is the source of truth, the campaign progress bars on Kat-Katha need to reflect a captured payment fast. I didn't reach for a websocket or a polling loop. The settlement webhook, after writing the ledger row, busts the ISR cache tag for that campaign type, so the next render serves fresh totals — progress bars update within about a second of a captured payment. Per-type cache tags mean a donation to one campaign doesn't needlessly invalidate the press page or the team page. It's the cheapest possible "realtime": the webhook that already knows something changed is the thing that invalidates the cache.
The operational guards that make it trustworthy
A correct happy path isn't enough; a public donation endpoint is a target. The hardening that ships with it:
- Per-IP rate limits plus a honeypot field on the donation form — bots that fill the hidden field get silently dropped, and no single IP can hammer order creation.
- Shared Zod schemas across client and server — the same validation runs in the browser for fast feedback and on the server as the real gate. One schema, no drift between what the form accepts and what the API trusts.
- Append-only audit trail of every webhook event, so settlement is forensically reconstructable.
- Graceful degradation — public pages fall back to seeded content if the database is down, so a DB blip can't take the whole site offline mid-campaign.
- Resend transactional email fired from inside the idempotent block, so a donor gets exactly one thank-you, never zero and never two.
On Trendverse, the same backbone guards inventory: cart and stock writes go through server actions, so two shoppers racing for the last unit can't both succeed — the write is serialized server-side, with no client-trusted quantity and no oversell.
What I'd tell my past self
- The webhook is the source of truth; the callback is just UX. Build fulfillment so it works even if the browser never came back.
- Read the raw body before you parse it. Webhook signatures are over bytes, not over your re-serialized object.
- Push "exactly once" down into a
UNIQUEconstraint. Application-level dedupe is a race; a database constraint is a guarantee. - Return 200 on duplicates. A non-2xx is an instruction to retry, and you do not want a retry storm.
- Keep the raw events forever. The audit trail costs almost nothing and answers the questions you can't predict.
The outcome on Kat-Katha: a donor gives in a few taps, gets an instant thank-you email, and watches the campaign total tick up within a second — while behind the scenes every rupee is recorded exactly once, even when the network does its worst.
Frequently asked questions
- Why isn't the Razorpay client callback enough to mark a payment complete?
- The client callback can be lost if the user closes the tab or the network drops, and it can be forged in dev tools. Treat it as an optimistic UX hint and use the server-to-server webhook, which fires independently of the browser, as the source of truth for fulfillment.
- How do I verify a Razorpay webhook signature in Next.js?
- Read the raw request body before parsing JSON, compute HMAC-SHA256 of those raw bytes using your webhook secret, and timing-safe compare it to the x-razorpay-signature header. Verify first, then JSON.parse. Re-serializing the parsed body changes key order and whitespace, so the HMAC won't match.
- How do I make a Razorpay webhook idempotent?
- Persist each event id under a UNIQUE constraint and insert with ON CONFLICT DO NOTHING. Run fulfillment only when the insert returns a new row; otherwise no-op and return 200. Razorpay delivers events at least once, so duplicates are expected and must not double-count.
- What's the difference between Razorpay checkout and webhook signature verification?
- Checkout verifies HMAC-SHA256 of 'order_id|payment_id' using your key secret. The webhook verifies HMAC-SHA256 of the raw request body using a separate webhook secret. Different inputs, different secrets, so using the wrong recipe silently fails until production.
Preventing double-bookings in Postgres: how a check-then-insert race oversells slots, and the layered fix — unique constraint, row and advisory locks, transactions, isolation.
ReadNext.js 15 caching as one mental model: Request Memoization, Data Cache, Full Route Cache, Router Cache, and tag-based revalidation that actually fires.
Read