Engineering

Razorpay Webhook Verification and Payment Idempotency

Abhishek Bahukhandi

Abhishek Bahukhandi

•10 min read
Taqari cover art for a deep dive on Razorpay webhook verification and payment idempotency
Taqari cover art for a deep dive on Razorpay webhook verification and payment idempotency
Razorpay webhook verification, idempotency keys, and reconciling a payment whose callback never arrived: the three server-side rules a checkout needs.

A payment flow is the only part of Taqari where being wrong costs somebody money. Everything else degrades: a voice interview that drops reconnects, a test run that stalls can be retried. A double charge is a support ticket and a refund, and a missed charge is a customer who paid for interviews they cannot start.

So the payment path assumes the browser is an untrusted narrator. This post is the three rules that follow: Razorpay webhook verification, idempotency on order creation, and what to do when the confirmation never arrives at all. It can tell us what it thinks happened — and we will check.

Why the browser is never allowed to say "paid"

Razorpay's checkout runs in the customer's browser and calls a handler function when it is done. That handler receives a payment id, an order id and a signature. It is tempting to treat it as the end of the story: the user saw a success screen, so activate the subscription.

Two things break that. The first is that a browser can lie — nothing stops someone calling your activation endpoint directly with made-up ids. The second is more common and more annoying: the browser can be right and still never tell you. A customer closes the tab on the success screen, the network drops between the bank and the browser, a phone goes to sleep mid-redirect. The money moved; your handler never ran.

Both failures point the same way. The client-side handler is a latency optimisation — it lets us show the right screen immediately — and the server has to be able to reach the same conclusion without it.

The one thing to take away: a payment has three possible confirmation paths — the browser handler, the webhook, and a reconciliation fetch — and all three must land on the same database row without activating anything twice. Idempotency is not a nicety bolted onto the end; it is the property that makes having three paths safe.

Order creation: the server decides the amount

Before checkout opens, the server creates a Razorpay order. This is where the amount gets fixed, and it is the first place a payment integration usually goes wrong.

The client sends a plan id, not a price

Our pricing component knows the display price because it has to render it. It does not get to send it. The request that creates an order carries a plan identifier and nothing else financial; the server maps that identifier to an amount from its own table.

The client then opens checkout using the amount that came back on the order, not the amount it rendered:

// Trust the server order as the single source of truth.
// orderData.amount is already in paise; currency comes from the order.
const options = {
  key: publishableKeyId,
  amount: orderData.amount,
  currency: orderData.currency,
  order_id: orderData.id,
  handler: async (response) => { /* verify on the server */ },
};

That looks redundant — we already knew the plan costs what it costs. It is not. The moment the client passes its own number, you have an endpoint where the price is an input, and the display price and the charged price can drift apart in a way no test catches.

The idempotency key goes in before the order does

Order creation is a POST, which means it is not idempotent, which means a retry can create a second order. Users double-click. Mobile networks resend. So the client generates a UUID and sends it with the request:

const idempotencyKey = uuidv4();

await fetch(createOrderUrl, {
  method: "POST",
  credentials: "include",
  body: JSON.stringify({ planId: plan.id, idempotencyKey }),
});

The IETF HTTPAPI working group's Idempotency-Key draft is the clearest specification of what the server should then do, and it is worth following even when you are passing the key in a body rather than a header. It defines three distinct cases, and most hand-rolled implementations only handle the first.

The original request already finished

The server should, in the draft's words, "respond with the result of the previously completed operation, success or an error." Note the second half. A retry of a request that failed returns the failure, it does not get a fresh attempt. That surprises people, but it is the only answer that makes the key meaningful.

The original request is still running

This is the double-click case, and the draft is specific: reply with 409 Conflict. Not a queue, not a wait — a conflict, because the server cannot yet know what the answer will be and must not start a second order to find out.

The same key arrives with a different payload

The draft says a key "MUST NOT be reused with another request with a different request payload," and that a server detecting this SHOULD return 422 Unprocessable Content. Detecting it requires storing something about the original payload — the draft calls this fingerprinting and leaves the method open, listing a checksum of the whole payload, field-value matching, or a request digest as options. It is optional in the spec. It is the difference between a key that prevents duplicate orders and a key that can be replayed to buy a different plan at the first plan's price.

Razorpay webhook verification: HMAC over the raw body

The webhook is the path that works when the browser does not. Razorpay signs each one, and the verification is a plain HMAC check — but the details are where the bugs live.

What the official SDK actually does

You do not have to guess at the algorithm, because the razorpay-node signature utilities are a few lines long. Webhook validation computes an HMAC-SHA256 hex digest over the request body with the webhook secret as the key, and compares it to the signature that arrived:

var expectedSignature = crypto.createHmac("sha256", secret)
  .update(body)
  .digest("hex");

return expectedSignature === signature;

Payment verification in the same file reuses that function with a constructed payload string rather than a body. For an order-based payment it is orderId + "|" + paymentId; subscriptions and payment links build different strings from their own ids. Which one it picks depends on which id is present, checked in order, and it throws if none is.

Two things are worth knowing about that snippet before you copy it.

Re-serialising the body breaks the digest

The digest covers bytes, not meaning. If your framework has already parsed the JSON and you hand the verifier a re-serialised copy, key order and whitespace can differ from what was signed and the check fails for a webhook that was perfectly genuine. The webhook route needs the raw buffer, which in practice means excluding it from your global JSON body parser. This is the single most common cause of a signature check that fails on every delivery.

The comparison is not constant-time

The SDK compares with ===. Node ships a function for exactly this comparison instead: the crypto documentation describes crypto.timingSafeEqual as a constant-time byte comparison intended for "comparing HMAC digests or secret values like authentication cookies," and states that it "does not leak timing information that" would help an attacker guess a value. The catch is that it throws when the inputs differ in byte length, so you check lengths before calling it rather than after.

Whether the timing channel on a hex digest is practically exploitable over the internet is arguable. Using the function that exists costs one line, so the argument is not worth having.

Three paths, one row

Put the pieces together and the shape of the system is three independent confirmation paths converging on one subscription record. The webhook is primary. The browser handler is fast. The sweep is the safety net.

Three confirmation paths — the browser handler, the signed webhook, and a periodic reconciliation fetch — all resolve against one idempotent activation record CONFIRMATION PATHS 1. Browser handler fast, may never fire sends ids + signature untrusted 2. Signed webhook HMAC-SHA256 over the raw body primary signal 3. Reconcile sweep fetch payments for stale pending orders safety net Verify, then resolve check signature key on payment id already applied? same answer, any path first writer wins others no-op Subscription row activated once interviews credited expiry set single source of truth Dashed = may or may not happen. Solid = expected on every successful payment.

The important property is in the middle box. Activation is keyed on the payment id, and applying it twice is a no-op rather than a second credit. Once that is true, having three paths is free — you can add a fourth without thinking about it. Until it is true, every extra path is a new way to double-credit an account. We reached the same conclusion for test-case results arriving from batch submission callbacks, where a webhook can be delivered more than once and the handler has to be safe about it.

When the callback never arrives

This is the case that decides whether your integration is finished. A customer's money has left their account, and nothing in your system knows.

The order is the thing you poll, not the payment

You do not have a payment id — that is the whole problem. What you have is the order id, because your server created it. The Orders API is built for this: fetching an order returns amount_paid, amount_due, attempts and a status that moves from created to attempted as customers try it, and fetching the payments for an order returns a collection of payment objects with their own status, captured flag, method, and any error_code.

So the sweep is simple to state: for every order still pending after some grace period, ask Razorpay what payments exist against it, and run any captured payment through the same activation code the webhook uses. Not a parallel code path — the same one, which is only possible because activation is idempotent.

Pick the grace period for the slow case, not the median

The temptation is to sweep aggressively, a minute after the order was created. Resist it. A payment that is briefly pending is normal, and a sweep that runs before the webhook gets a chance mostly generates API calls that tell you nothing. The grace period wants to be long enough that the ordinary path has clearly failed, and the sweep interval wants to be a lot longer than the webhook's own delivery window.

There is also a quieter failure this catches: the order that never gets paid. Those rows accumulate, and a sweep that can distinguish "paid, we missed it" from "abandoned at checkout" is also the thing that keeps your pending table from becoming a graveyard.

A bug we shipped: three checkout iframes before anyone clicked

The payment code in this repo carries a comment about a mistake worth repeating, because it has nothing to do with correctness and everything to do with what payment integrations cost the people who never pay.

The pay button loads Razorpay's checkout script. The first version loaded it from a useEffect on mount. That component renders once per pricing plan, and we have three plans — so the landing page appended the script three times, and checkout injected three hidden iframes before anybody clicked anything. Third-party JavaScript and main-thread work charged to every single visitor, almost none of whom were going to buy.

The fix is to load on demand and share one promise across callers:

let razorpayLoader = null;

function loadRazorpayCheckout() {
  if (window.Razorpay) return Promise.resolve();
  if (razorpayLoader) return razorpayLoader;

  razorpayLoader = new Promise((resolve, reject) => {
    const el = document.createElement("script");
    el.src = "https://checkout.razorpay.com/v1/checkout.js";
    el.async = true;
    el.onload = () => resolve();
    el.onerror = () => {
      // Allow a retry on the next click rather than caching the failure.
      razorpayLoader = null;
      el.remove();
      reject(new Error("Failed to load Razorpay"));
    };
    document.body.appendChild(el);
  });

  return razorpayLoader;
}

The module-scoped promise means three buttons mounting at once share a single fetch. The onerror branch nulling the loader back out matters more than it looks: cache the rejected promise and a customer whose first script load failed on a flaky connection can never pay, because every subsequent click resolves instantly to the same failure. The same instinct — keep expensive work out of the render path — is what drove our decisions about server versus client components.

What we would tell someone starting today

Four rules, in the order they bite:

  • The server owns the amount. The client sends a plan id; the server looks up the price and creates the order. Open checkout with the amount the order came back with.
  • Make activation idempotent before you add confirmation paths. Key it on the payment id. Once applying it twice is a no-op, every other problem here gets easier.
  • Verify the raw body. HMAC-SHA256, the webhook secret as key, the unparsed buffer as the message, and timingSafeEqual for the comparison. Keep the webhook route out of your global JSON parser.
  • Write the sweep. Not later. The webhook will be missed eventually, and the sweep is the difference between a customer noticing and you noticing.

None of this is specific to Razorpay. Swap the signature format and the Orders API and the same four rules describe a Stripe or PayPal integration, because they follow from one fact about payments: the money moves in a system you do not control, and your database finds out afterwards, over an unreliable channel, possibly more than once.

The auth layer underneath all of this — keeping a paid session valid through a fifty-minute interview without weakening logout — is its own problem, and we wrote about refresh token rotation for long sessions separately. If you want to see what the subscription actually buys, the free AI mock interviews on Taqari run without a card at all.

Frequently asked questions

How does Razorpay webhook verification work?

+

Razorpay signs each webhook with an HMAC-SHA256 digest of the request body, keyed with the webhook secret. Your server recomputes that digest over the raw body and compares it to the signature on the request. The official Node SDK does exactly this and returns a boolean.

Why does my Razorpay signature check keep failing?

+

Almost always because the bytes changed. The digest is computed over the raw request body, so if your framework parses the JSON and you re-serialise it before verifying, key order and whitespace can differ and the digest will not match. Verify the raw buffer.

What is an idempotency key in a payment flow?

+

A client-generated unique value, usually a UUID, sent on a non-idempotent request so the server can recognise a retry. The IETF draft specifies that a retry with the same key should return the result of the original operation rather than performing it twice.

Should I trust the amount sent by the browser?

+

No. Treat the price the client sends as a request, not a fact. The client should send a plan identifier, the server should look up the amount, and checkout should be opened with the amount that came back on the server-created order.

What happens if a payment callback never reaches my server?

+

The money can still have moved. Because of that, the browser callback cannot be your only confirmation path. Treat the webhook as the primary signal and run a periodic sweep that fetches the payments recorded against any order still sitting in a pending state.

Is comparing two HMAC digests with === safe?

+

It leaks timing information in principle. Node provides crypto.timingSafeEqual for comparing HMAC digests and secret values, which the docs describe as a constant-time byte comparison. It throws when the two inputs differ in byte length, so check lengths first.

Sources

Did you find this helpful?

Share this guide with your circle.

Keep reading

Preparing for an interview?

Start with these guides, then practise a full round with the AI interviewer.

#razorpay webhook verification#payment idempotency key#razorpay payment verification#razorpay order creation#webhook signature hmac sha256#idempotency key header