VibeSecurity

India

Razorpay Webhook Signature Verification for AI-Built Apps

This guide is for Indian founders who asked an AI tool to add Razorpay to their app and got a working checkout. Working is not the same as safe. The generated code often marks an order paid inside the browser's success handler, skips the signature check, or trusts a webhook body without verifying who sent it. Each of those is a way to get free product. After reading this you will know the four places a Razorpay integration must verify, how to compute the HMAC SHA256 signatures Razorpay documents, how to handle duplicate and out-of-order webhooks, and where key_secret and the webhook secret are allowed to live.

By the VibeSecurity team11 min read

Why the client success callback is not proof

Razorpay's Standard Checkout opens in the customer's browser. On a successful payment the checkout hands your JavaScript handler three values: razorpay_payment_id, razorpay_order_id and razorpay_signature. AI-generated integrations frequently take that handler and call an endpoint such as /api/orders/mark-paid, or worse, update the database from the client directly through Supabase. The problem is that anything in the browser is under the customer's control. They can call your mark-paid endpoint with any order id, no payment required.

Razorpay's integration guide is explicit that you must verify the signature on your server before fulfilling the order, and that a failed signature check indicates a potentially fraudulent or tampered payment. The signature exists precisely because the handler's values arrive through an untrusted path. Treat them as a claim to be checked, not a fact to be stored.

The right shape is: the client posts the three values to your backend, the backend verifies the signature with key_secret, and only then does the order change state. Even that is a convenience path for a quick confirmation screen. The durable source of truth is the webhook, because a customer can close the tab before the handler ever runs.

Create the order on the server first

Razorpay's flow starts with an order created through the Orders API using your key_id and key_secret over Basic Auth. The docs say an order should be created for every payment, with amount expressed in the currency subunit, so 50000 means 500 rupees, and a currency such as INR. The order id that comes back is what you pass to Checkout, and it is the id that appears in the signature later.

The important habit is to store the amount and currency you sent in your own database against your own order row, keyed by the Razorpay order id. That record is what every later verification compares against. If the amount only lives in the client-side Checkout options, a customer can edit it, and you have nothing authoritative to check the payment against.

Razorpay has separate Test and Live modes with separate key pairs. Test mode runs the flow without customers being able to pay; when the integration is complete you generate live keys and swap them in. Keep the pairs in separate environments so a live key never reaches a developer laptop.

Node: create the order server-side and record it
import Razorpay from "razorpay";

const rzp = new Razorpay({
  key_id: process.env.RAZORPAY_KEY_ID,
  key_secret: process.env.RAZORPAY_KEY_SECRET,
});

export async function createOrder(userId, amountPaise) {
  const order = await rzp.orders.create({
    amount: amountPaise,
    currency: "INR",
    receipt: `user-${userId}-${Date.now()}`,
  });
  await db.orders.insert({
    id: order.id,
    userId,
    amount: amountPaise,
    currency: "INR",
    status: "created",
  });
  return { orderId: order.id, amount: amountPaise };
}

Verify razorpay_signature after checkout

Razorpay documents the checkout signature as HMAC SHA256 over the string order_id + "|" + razorpay_payment_id, keyed with your key_secret. If the value you compute equals razorpay_signature, the payment id and order id genuinely came from Razorpay for your account. If it does not, reject the request and do not change the order.

Two details matter in code. Compare the two hex strings with a constant-time comparison so an attacker cannot learn the signature byte by byte from response timing. And look up the order by razorpay_order_id in your own table, not by anything the client sends about price, so a valid signature on a cheap order cannot unlock an expensive one. After the signature passes, record the payment id and mark the order pending confirmation rather than paid if you can wait for the webhook; if you must fulfil instantly, do so only after the amount check below.

Node: verify the checkout signature on your server
import crypto from "node:crypto";

export function verifyCheckoutSignature({ orderId, paymentId, signature }) {
  const expected = crypto
    .createHmac("sha256", process.env.RAZORPAY_KEY_SECRET)
    .update(`${orderId}|${paymentId}`)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(signature, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

Verify X-Razorpay-Signature on every webhook

Webhooks are set up from the Razorpay Dashboard with separate URLs for Live and Test mode, and the URL must use port 80 or 443. Each webhook carries an X-Razorpay-Signature header. Razorpay's validation page says the signature is calculated using HMAC with SHA256, with your webhook secret as the key and the webhook request body as the message. The webhook secret is a different value from key_secret, and it is set when you create the webhook.

The mistake that breaks almost every framework-generated handler is parsing the body first. Razorpay's docs say to ensure the webhook body passed to verification is the raw webhook request body, and not to parse or cast it. In Next.js App Router, read the request as text before calling JSON.parse. In Express, capture the raw buffer with a verify hook on the JSON parser or use express.raw for that route. If you re-serialize a parsed object, key order and whitespace can change and the signature will fail.

If you rotate the webhook secret, Razorpay notes that retries of older events are still signed with the old secret, so keep the previous secret available for validation during the retry window. Razorpay's Node SDK ships validateWebhookSignature for this, but the plain crypto version below shows exactly what is being checked.

Next.js App Router: verify the webhook on the raw body
import crypto from "node:crypto";

export async function POST(req: Request) {
  const raw = await req.text();
  const signature = req.headers.get("x-razorpay-signature") ?? "";
  const expected = crypto
    .createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!)
    .update(raw)
    .digest("hex");
  const ok =
    expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  if (!ok) return new Response("invalid signature", { status: 400 });

  const eventId = req.headers.get("x-razorpay-event-id") ?? "";
  const event = JSON.parse(raw);
  await handleEvent(eventId, event);
  return new Response("ok", { status: 200 });
}

Idempotency: the same event will arrive twice

Razorpay's webhook best practices describe at-least-once delivery. Any event that receives a non-2xx response is treated as a delivery failure and retried with exponential backoff for 24 hours after the event was created. Your endpoint must respond within 5 seconds, or the session is marked as a timeout and the event is sent again even if you processed it. After 24 hours of consecutive failures the webhook is disabled and must be re-enabled from the Dashboard once the errors are fixed.

The practical consequence is that a slow handler that credits a wallet, sends a confirmation email and then responds will run twice. Razorpay provides an x-razorpay-event-id header whose value is unique per event and is the documented way to detect duplicates. Store it with a unique constraint before doing any work; if the insert fails because the id exists, return 200 and stop.

Ordering is not guaranteed either. Razorpay says you should ideally receive events in the order they occur, but you may not. Design state transitions so that a later event cannot regress an order: if order.paid has already been processed, an earlier payment.authorized arriving late should be ignored, not used to reset status.

SQL: dedupe webhook events by x-razorpay-event-id
create table razorpay_events (
  event_id text primary key,
  event text not null,
  received_at timestamptz not null default now()
);

insert into razorpay_events (event_id, event)
values ($1, $2)
on conflict (event_id) do nothing;

Check amount and currency against your own order

A valid signature proves Razorpay sent the event. It does not prove the customer paid what you expected. Razorpay's payment webhook payloads carry the payment entity with id, order_id, amount, currency and status fields; payment.captured has status captured, and order.paid includes both the order and payment entities. Load your order row by order_id and compare amount and currency to what you stored when you created the order.

This defends against a subtle class of bug in AI-generated code where the client is allowed to create the Razorpay order, or where the checkout amount is read from a form field. If a customer manages to pay 1 rupee against an order you priced at 999, the signature is perfectly valid. Only the comparison against your record catches it. Reject or flag any mismatch, and never fulfil on a partial amount unless partial payment is a deliberate feature.

Also check that the order is in a state that expects payment. A second payment against an already-fulfilled order, or a payment against a cancelled order, should be logged for a human to refund rather than silently accepted. The same goes for a payment whose order_id you have never seen: it is either a bug in your order creation or someone probing your webhook, and neither deserves fulfilment.

The four verification points and what skipping each one hands an attacker
Verification pointWhat you checkIf skipped, an attacker can
Server-side order creationAmount and currency come from your pricing logic and are stored on your order rowPay a tiny amount for an expensive item by editing the client-side amount
Checkout signatureHMAC SHA256 of order_id|payment_id with key_secret equals razorpay_signatureCall your mark-paid endpoint with invented ids and never pay at all
Webhook signatureHMAC SHA256 of the raw body with the webhook secret equals X-Razorpay-SignaturePost a forged payment.captured event to your webhook URL and get free fulfilment
Amount, currency and idempotencyPayload amount and currency match your record; x-razorpay-event-id not seen beforeUnderpay against a valid order, or replay one real payment to unlock several orders

Keep key_secret and the webhook secret server-side

Razorpay shows only the Key Id in the Dashboard after generation; the secret is shown at creation and the docs warn not to share it with anyone or on any public platform because it poses a security threat to your account. In your app, key_id is the only value the browser needs to open Checkout. key_secret is used for creating orders and verifying signatures, and the webhook secret is used only inside the webhook route. None of those belong in client code.

The way secrets leak from vibe-coded apps is the environment variable prefix. NEXT_PUBLIC_, VITE_ and EXPO_PUBLIC_ variables are compiled into the JavaScript that every visitor downloads. A generated .env with NEXT_PUBLIC_RAZORPAY_KEY_SECRET is a public secret. With it, anyone can create orders on your account, forge checkout signatures and make refunds through the API. Name the key id public and the secret plain, and grep your build output to prove the secret is absent.

If a secret has already shipped or been committed, regenerate it. Razorpay lets you generate a new key pair from Account and Settings, API Keys, and deactivate the old one immediately or within 24 hours. For a confirmed leak choose immediately, and rotate the webhook secret separately if that was exposed. Our guide on rotating a leaked API key covers the full sequence.

  • NEXT_PUBLIC_RAZORPAY_KEY_ID: fine, the browser needs it.
  • RAZORPAY_KEY_SECRET: server only, used for order creation and signature checks.
  • RAZORPAY_WEBHOOK_SECRET: server only, used only in the webhook route.
  • Test and Live pairs in separate environments; never both in one .env.

A ten-minute audit of your current integration

Open the generated code and answer these questions honestly. Where is the Razorpay order created, and does the amount come from your server? What does the checkout handler call, and does that endpoint verify the signature before touching the order? Does the webhook route read the raw body, verify X-Razorpay-Signature, and record x-razorpay-event-id before doing work? Does anything compare the paid amount to your own record?

Then test the failure paths rather than the happy path. Post a made-up payment id to your mark-paid endpoint and confirm it is rejected. Send your webhook URL a JSON body with no signature and confirm you get a 400. Replay a captured real event and confirm the order is not fulfilled twice. Finally, search your built client bundle for the string rzp_live and for any value that looks like a secret; a read-only external scan such as VibeSecurity can do that last check against the deployed site.

Every payment provider gives you a signed confirmation and expects you to verify it on the server, so none of this is specific to Razorpay in spirit. The AI tool that wrote your checkout optimised for a green tick in the preview; the four checks above are what make it safe to take live with real money.

  1. 1Order created server-side with amount and currency stored on your row.
  2. 2Checkout handler posts ids to the server; server verifies razorpay_signature with key_secret.
  3. 3Webhook route verifies X-Razorpay-Signature on the raw body with the webhook secret.
  4. 4Event id stored with a unique constraint before any side effects.
  5. 5Amount and currency in the payload compared to your record before fulfilment.
  6. 6No secret under a NEXT_PUBLIC_, VITE_ or EXPO_PUBLIC_ name.

Frequently asked questions

How do I verify a Razorpay webhook signature?

Compute HMAC SHA256 over the raw request body using your webhook secret as the key, hex encode it, and compare it in constant time to the X-Razorpay-Signature header. Razorpay's docs stress using the raw body, not a parsed and re-serialized object. Reject the request with a 400 if the values differ, and only then parse the JSON.

What is the difference between key_secret and the webhook secret?

key_secret is half of your API credential pair, used with key_id for Basic Auth when creating orders and for verifying the checkout signature of order_id|payment_id. The webhook secret is a separate value you set when creating a webhook in the Dashboard, used only to verify X-Razorpay-Signature on incoming events. Both stay on the server.

Can I trust the payment success callback in the browser?

No. The callback delivers razorpay_payment_id, razorpay_order_id and razorpay_signature, but it runs in the customer's browser, so anything it sends can be forged. Razorpay says to verify the signature on your server before fulfilling the order. Use the callback to show a confirmation, and use the server check plus the webhook to change order state.

Why does Razorpay send the same webhook twice?

Razorpay uses at-least-once delivery. If your endpoint returns a non-2xx status or takes longer than 5 seconds, the event is retried with exponential backoff for up to 24 hours after creation. Deduplicate using the x-razorpay-event-id header, which is unique per event, by storing it with a unique constraint before doing any work.

Is it safe to put my Razorpay key in NEXT_PUBLIC_ variables?

Only the key_id. NEXT_PUBLIC_, VITE_ and EXPO_PUBLIC_ variables are bundled into client-side JavaScript that any visitor can read. key_secret and the webhook secret must be plain server-side variables. Razorpay warns that a shared key secret poses a security threat to your account, so if one has been exposed, regenerate the keys immediately.

Put it into practice

Sources

  1. 1.Razorpay: Build Standard Checkout integration
  2. 2.Razorpay: Validate webhook signature
  3. 3.Razorpay: Webhook best practices
  4. 4.Razorpay: Webhooks overview
  5. 5.Razorpay: Payment webhook payloads
  6. 6.Razorpay: API authentication
  7. 7.Razorpay: Generate API keys