Stripe webhooks / raw body

Stripe: “Webhook payload must be provided as a string or a Buffer instance representing the raw request body”

StripeSignatureVerificationError: Webhook payload must be provided as a string or a Buffer instance representing the raw request body.

This is the single most common Stripe webhook failure. On Stack Overflow, the canonical question for this exact message has 25,122 views and 13 answers, and the same root cause drives several more: the Next.js 13 variant has 11,915 views, and there are near-identical reports for Express, Bun/ElysiaJS and AWS Lambda. Roughly 40,000 developers have hit this one error and gone looking for the answer.

The good news: the cause is always the same, and the fix is three lines. The bad news: the obvious fix breaks the rest of your API if you apply it in the wrong place, which is why the thread has thirteen answers instead of one.

What the error actually means

Stripe signs the exact bytes it sent you. The Stripe-Signature header contains an HMAC-SHA256 of timestamp + "." + raw_body, computed over that byte sequence and nothing else.

When a JSON body parser runs before your handler, you no longer have those bytes. You have a JavaScript object (or a Python dict) that was re-serialized back into a string when you passed it to constructEvent. Re-serialization is not byte-preserving:

So the HMAC you compute can never equal the HMAC Stripe computed — not "usually not", never. The Stripe SDK detects that you handed it an object instead of raw bytes and raises this error before it even tries.

The corollary that trips people up: this is not a bug in your signing secret. Changing whsec_ values, regenerating endpoints or switching from test to live mode will not fix it. The body is the problem.

The fix, per framework

Express — the ordering trap

The mount order matters more than the parser. A global express.json() declared before your webhook route has already consumed the stream by the time your handler runs, even if the route itself uses express.raw().

// WRONG — global parser runs first, body is already an object
app.use(express.json());
app.post('/webhook', express.raw({ type: 'application/json' }), handler);

// RIGHT — the webhook route is registered before the global parser
app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,                          // a Buffer, untouched
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }
    res.json({ received: true });          // ack fast, work after
  }
);

app.use(express.json());                   // everything else, after

If you cannot reorder (a router mounted in another file, a framework that installs its own parser), skip the parser for that path instead:

app.use((req, res, next) =>
  req.originalUrl === '/webhook' ? next() : express.json()(req, res, next)
);

Next.js — App Router (13/14/15)

Route handlers do not pre-parse the body, so there is no config flag to disable. Just read it as text and pass the string:

// app/api/webhook/route.js
export async function POST(req) {
  const body = await req.text();                    // NOT req.json()
  const sig  = req.headers.get('stripe-signature');
  try {
    const event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
    return Response.json({ received: true });
  } catch (err) {
    return new Response(`Webhook Error: ${err.message}`, { status: 400 });
  }
}

Next.js — Pages Router

Here the body parser is on by default and you must turn it off, then buffer the stream yourself:

// pages/api/webhook.js
import { buffer } from 'micro';

export const config = { api: { bodyParser: false } };   // required

export default async function handler(req, res) {
  const buf = await buffer(req);
  const sig = req.headers['stripe-signature'];
  try {
    const event = stripe.webhooks.constructEvent(
      buf, sig, process.env.STRIPE_WEBHOOK_SECRET
    );
    res.json({ received: true });
  } catch (err) {
    res.status(400).send(`Webhook Error: ${err.message}`);
  }
}

Forgetting bodyParser: false is the most reported cause of the Next.js variant of this error.

Flask

@app.route('/webhook', methods=['POST'])
def webhook():
    payload = request.get_data()               # bytes, NOT request.get_json()
    sig = request.headers.get('Stripe-Signature')
    try:
        event = stripe.Webhook.construct_event(
            payload, sig, os.environ['STRIPE_WEBHOOK_SECRET']
        )
    except stripe.error.SignatureVerificationError:
        return '', 400
    return '', 200

FastAPI

@app.post('/webhook')
async def webhook(request: Request):
    payload = await request.body()             # NOT a Pydantic model
    sig = request.headers.get('stripe-signature')
    ...

Declaring a Pydantic model as the endpoint parameter is what silently parses the body. Take Request and read it yourself.

Django

@csrf_exempt
def webhook(request):
    payload = request.body                     # bytes; do not use request.POST
    sig = request.META['HTTP_STRIPE_SIGNATURE']
    ...

Bun / ElysiaJS

Elysia parses JSON bodies automatically based on Content-Type. Ask for the raw request instead:

.post('/webhook', async ({ request }) => {
  const body = await request.text();
  const sig  = request.headers.get('stripe-signature');
  ...
})

AWS Lambda / API Gateway

Two things bite here. If isBase64Encoded is true you must decode before verifying, and API Gateway may normalise header casing:

const raw = event.isBase64Encoded
  ? Buffer.from(event.body, 'base64')
  : event.body;                                // string or Buffer, never JSON.parse
const sig = event.headers['stripe-signature'] ?? event.headers['Stripe-Signature'];

Also check that no proxy, WAF or middleware layer rewrites the body in transit — reformatting JSON at the edge produces exactly this error with correct application code.

Check your own handler in 30 seconds

Free, no signup, nothing leaves your browser

Paste your webhook handler into the scanner

We built a static checker that runs 7 rules against a pasted handler — raw-body reaching constructEvent, signature actually verified, hardcoded sk_/whsec_ literals, idempotency keys, event.id de-duplication, amounts read from request data, legacy Charges API. It runs entirely in your browser: no upload, no backend, and it never asks for an API key or a signing secret.

Open the handler scanner — free

Related free tools: signature validator · timestamp tolerance debugger

The three errors you hit right after this one

MessageCauseFix
No signatures found matching the expected signature for payload Raw body is now correct, but the secret belongs to a different endpoint. The Stripe CLI, each dashboard endpoint and each mode have their own whsec_. Copy the secret from the exact endpoint sending this request. Never hardcode it.
Timestamp outside the tolerance zone Server clock drift, or a replayed event older than the 5-minute default tolerance. Fix NTP on the host before widening tolerance. Test your timestamps here.
Everything verifies, customers charged twice Stripe delivers at least once and retries any non-2xx or timeout. A handler without event.id de-duplication or idempotency keys runs your fulfillment twice. Store processed event.ids; derive idempotency keys from the event, not from uuid4().

That last row is the expensive one. The raw-body error is loud and fails closed — you find it in five minutes. Duplicate fulfillment is silent and fails open: it works in testing, and you find out when a customer emails about being charged twice.

Want a human to read the whole integration?

Done-for-you

Stripe Integration Audit — $39

The scanner reads one pasted file with static pattern checks. It cannot see your env vars, your proxy, whether that express.json() is mounted on a different router, or what your database does with event.id. For that, send us a public repo URL: we read the Stripe code by hand — webhooks, checkout, subscriptions, refunds — run the same 7 rules across the whole tree, and send back a written report with file:line references and the fix for each finding.

Delivered within 48 hours at a private URL. Full refund if the repo can't be reviewed. See a real sample report first.

$39 one-time

Get your audit — $39


Source threads

View counts checked against the Stack Exchange API on 15 September 2026: