Skip to the tool
MoveAheadPayments Toolbox

Why your webhook signature fails in Next.js App Router

The framework, not the gateway. request.json() and even request.text() can hand you something that is no longer byte-identical to what was signed, and the mismatch looks exactly like a wrong secret.

You copied the verification code from the gateway's documentation. The secret is right. The event is real. Every delivery fails, and the error says the signature does not match — which sounds like a tampering problem and is actually a framework problem.

A signature is computed over exact bytes. Anything that parses the body and hands you an object has thrown those bytes away, and the string you get back from re-serialising is a different message as far as an HMAC is concerned.

What changed from the Pages Router

In the Pages Router this was a configuration problem with a well-known answer:

pages/api/webhook.ts — the old way
export const config = { api: { bodyParser: false } };

That setting does not exist in App Router, and copying it does nothing. A Route Handler never parses the body for you. It hands you a Request and you decide, by choosing a method:

app/api/webhook/route.ts
export async function POST(request: Request) {
  await request.json();         // parsed — the bytes are gone
  await request.text();         // decoded as UTF-8
  await request.arrayBuffer();  // the bytes, exactly as they arrived
}

So there is no config to get wrong, and no error to tell you that you chose badly. The handler works, the parse succeeds, and only the signature check fails — which is why people go looking at their secret.

Why JSON.stringify(await request.json()) is not the same message

This is the fix people try first, and it is the one that produces the most confusing failure, because it sometimes works.

Here is a real payload signed as sent, and signed again after a JSON round trip, both computed by the same function the verifier uses:

Signed as sent
{"entity":"event","account_id":"acc_BFQ7uQEaa7j2z7","event":"payment.captured","contains":["payment"],"created_at":1739510400}

397bb1d09334c5158b5bc4f63c5a9f3537970bad6755f50a6d2766dc483f348a
Signed after JSON.parse then JSON.stringify
{"entity":"event","account_id":"acc_BFQ7uQEaa7j2z7","event":"payment.captured","contains":["payment"],"created_at":1739510400}

397bb1d09334c5158b5bc4f63c5a9f3537970bad6755f50a6d2766dc483f348a

Those two digests are identical — and that is the trap, not the reassurance it looks like. This payload happens to survive the round trip because it has no insignificant whitespace, no unicode escapes and no numbers whose formatting could change. Nothing guarantees the next one will.

Add the whitespace a pretty-printer or an intermediate proxy might introduce, and the same document signs completely differently:

The identical data, indented
6a09b5d651e0effec1542c8f1eea51c14a85b172a482a850321b59def3923806

The fix

app/api/webhooks/razorpay/route.ts
import { verifyWebhookSignature } from "pymnt-tools/webhook-signature";

export async function POST(request: Request) {
  // Bytes first, exactly as they arrived.
  const raw = new Uint8Array(await request.arrayBuffer());
  const body = new TextDecoder().decode(raw);

  const result = await verifyWebhookSignature("razorpay", {
    payload: body,
    secret: process.env.RAZORPAY_WEBHOOK_SECRET ?? "",
    signature: request.headers.get("x-razorpay-signature") ?? "",
  });

  if (result.status !== "valid") {
    // steps[] shows which string was signed — usually the whole answer.
    console.error(result.summary, result.steps);
    return new Response("invalid signature", { status: 400 });
  }

  // Parse only after verifying, and from the bytes you already have.
  const event = JSON.parse(body);
  return Response.json({ received: true, type: event.event });
}

Three things in that handler are doing work:

  • Read once, as bytes. The request stream is consumed by the first read. Calling arrayBuffer() and then json() on the same request throws, and working around that by parsing first is precisely what breaks the signature.
  • Verify before parsing. An unverified payload is attacker-controlled input. Parsing it first is not dangerous in itself, but acting on anything from it is.
  • Parse from the same buffer. Not from a second read, which does not exist, and not from a re-serialised copy.

Is text() good enough?

For any gateway sending UTF-8 JSON — and Razorpay, PayU, Cashfree and Stripe all do — request.text() is fine, and it is what most examples show.

It stops being fine the moment a body is not valid UTF-8, because text() substitutes U+FFFD for every byte it cannot decode, and those replacement characters are what your HMAC then covers. The bytes that were signed no longer exist in the string you are checking. arrayBuffer() is exact for every input, which is why it is the better habit even where text() happens to work.

Two more things that eat the bytes

  • Middleware, or proxy.ts in Next.js 16. Anything that reads the body upstream of the handler consumes the stream. If a signature check started failing after you added auth middleware, check whether the matcher covers the webhook route — it usually should not.
  • curl -d when you are testing. It strips carriage returns and newlines, which changes the bytes and therefore the signature. Use --data-binary. This is the most common way an online example turns a working webhook into a mismatch.

Telling a framework problem from a secret problem

They present identically: one string does not equal another. The difference is visible only if you can see what was signed.

Paste the raw body, the secret and the header into the signature verifier. If it says valid, your bytes are fine and the handler is losing them. If it says invalid on the exact bytes the gateway sent, the secret or the scheme is wrong instead — and the three Indian gateways each differ, which is covered in webhook signature verification for Razorpay, PayU and Cashfree.

To capture the exact bytes in the first place, point the gateway at the free test endpoint. It stores what arrived byte for byte, which is the thing your handler is throwing away.

Check it against the tool

  • Webhook signature verifier

    Check a Razorpay, Stripe, Cashfree or PayU signature against your secret — webhooks, and Razorpay's checkout and payment-link schemes — and see exactly which string was signed.

  • Free webhook testing endpoint

    Get a URL that catches webhooks for fifteen minutes: see the exact bytes that arrived, check the signature against your secret in your browser, and forward every delivery to localhost with one command — no tunnel.

Frequently asked questions

Is request.text() safe for webhook verification?
For any gateway sending UTF-8 JSON, yes — and all three Indian gateways do. It stops being safe the moment a body is not valid UTF-8, because text() substitutes U+FFFD for the bytes it cannot decode and those replacement characters are what your HMAC then covers. arrayBuffer() is exact for every input, which is why it is the safer default even where text() happens to work.
Do I still need bodyParser: false?
No. That was a Pages Router API route setting and it has no equivalent in App Router, because Route Handlers never parse the body for you — you choose by calling json(), text() or arrayBuffer(). Copying the old config into an App Router handler does nothing at all, which is why people conclude the config is broken rather than that they called the wrong method.
Why does JSON.stringify(await request.json()) not work?
Because it produces a valid JSON document that is almost never byte-identical to the one that was signed. Key order can survive, but whitespace, unicode escaping and number formatting do not have to. The gateway signed the bytes it sent; you are signing the bytes your serialiser produces, and any difference at all is a total digest mismatch.
Can I read the body twice?
Not from the same Request — the stream is consumed. Read it once as bytes, verify against those bytes, and parse from the same buffer afterwards. Calling arrayBuffer() and then json() on the same request throws, and working around it by parsing first is exactly what breaks the signature.