Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)
Technology

Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)

If Stripe webhooks return `Webhook signature verification failed`, your Next.js route is parsing the JSON before Stripe sees it. Here's the exact raw-body pattern for App Router, Pages Router, and Vercel Edge — plus the three secret-mismatch traps that cause the same error.

2026-05-19
8 min read
Stripe Webhook Signature Verification Failed in Next.js (Production Fix + Retry Strategy 2026)

If your Stripe integration just broke after deploying to Next.js, this is almost certainly the bug. The error message is generic, the cause is specific, and the fix is one line.

The problem#

Your webhook handler logs:

Webhook signature verification failed: No signatures found matching the
expected signature for payload. Are you passing the raw request body you
received from Stripe?

Stripe returns HTTP 400 to itself, so you see failures in the Stripe Dashboard → Developers → Webhooks → your endpoint → "Failed events." Customers complete checkout but their subscription never activates because your customer.subscription.created handler never runs.

Symptoms#

  • The Stripe Dashboard shows the events were sent but returned 400.
  • Local testing with stripe trigger from the CLI also fails.
  • The error mentions "the raw request body."
  • Your route handler works fine if you skip verification (which you absolutely should not ship).

Root cause#

Stripe computes an HMAC-SHA256 of the request body bytes, signs it with your webhook secret, and sends the result in the Stripe-Signature header. Verification re-computes the hash on your end and compares.

The hash depends on the exact bytes. If you parse the JSON first, even formatting differences (key order, whitespace) change the hash. Next.js framework helpers like req.json() or bodyParser always re-serialize, so the bytes you pass to constructEvent are not the bytes Stripe signed.

There are two flavors of this bug:

  1. You called req.json() before verifying. This is the most common cause.
  2. Your webhook secret is wrong. Same error message, different fix. Always check #1 first because it's a code bug; check #2 second because it's a config bug.

Fix — App Router (Next.js 13+)#

app/api/webhooks/stripe/route.js:

js
import { NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

export async function POST(req) {
  // Critical: read as TEXT, not JSON. Stripe signs the raw bytes.
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');

  let event;
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Now it's safe to use the parsed event
  switch (event.type) {
    case 'checkout.session.completed':
      // handle...
      break;
    case 'customer.subscription.updated':
      // handle...
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

The line that fixes 95% of cases: const body = await req.text(). Do not replace it with req.json() "for convenience" — constructEvent returns the parsed object for you.

Fix — Pages Router (Next.js 12 and below)#

The Pages Router auto-parses JSON. You need to disable it for this route:

js
// pages/api/webhooks/stripe.js
import Stripe from 'stripe';
import { buffer } from 'micro';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

// Disable Next.js body parsing — Stripe needs the raw bytes.
export const config = {
  api: {
    bodyParser: false,
  },
};

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).end();
  }

  const buf = await buffer(req);
  const signature = req.headers['stripe-signature'];

  let event;
  try {
    event = stripe.webhooks.constructEvent(buf, signature, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message);
    return res.status(400).json({ error: 'Invalid signature' });
  }

  // Handle event...
  res.json({ received: true });
}

micro's buffer() returns the raw bytes. If you don't want the micro dep, you can read the stream manually — but micro is already a Next.js transitive dependency so importing it adds nothing.

Fix — Edge runtime#

js
export const runtime = 'edge';

export async function POST(req) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');

  let event;
  try {
    // constructEventAsync, not constructEvent — Edge has no sync crypto
    event = await stripe.webhooks.constructEventAsync(
      body,
      signature,
      webhookSecret
    );
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  // ...
  return Response.json({ received: true });
}

The secret-mismatch trap#

If the code above is already correct, the error is your webhook secret. Common cases:

  1. You copied the wrong secret from the dashboard. Stripe shows the secret only once when you create the endpoint — if you regenerated it, the old STRIPE_WEBHOOK_SECRET is dead.
  2. Test mode vs live mode mismatch. Test webhooks have their own secret, distinct from live. Toggle the mode toggle in the dashboard and check both.
  3. Local CLI secret vs dashboard secret. stripe listen prints a different secret every time it starts. Locally you want the CLI one (whsec_...), in production you want the dashboard one. They are not interchangeable.
  4. Environment variable not deployed. Set it on Vercel and redeploy — env-var changes don't apply to existing deployments.

To verify the secret is loading at runtime:

js
console.log('Secret prefix:', webhookSecret?.slice(0, 8));
// Should log "whsec_xx" — if undefined or "whsec_te" vs "whsec_li" mismatch, fix env

Remove the log before shipping.

Local testing with the Stripe CLI#

bash
# Terminal 1: forward webhooks to local Next.js dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Note the printed secret — set it in .env.local for this session
# > Ready! Your webhook signing secret is whsec_abc123...

# Terminal 2: trigger a test event
stripe trigger checkout.session.completed

If verification still fails locally with the CLI secret, the bug is your code (raw-body issue). If it works locally but fails on Vercel, the bug is env config (production secret mismatch).

Debug checklist#

  1. Is req.text() called before constructEvent? If not, fix code first.
  2. Does the secret start with whsec_? If it starts with sk_, you used the API key, not the webhook secret.
  3. Is the env var loaded? Log the prefix temporarily.
  4. Test mode vs live mode? Check both keys.
  5. Did you redeploy after setting the env var? Vercel doesn't apply env changes retroactively.
  6. Is the endpoint URL in the Stripe Dashboard correct? A typo means events go nowhere.
  7. Is there middleware modifying the request? Authentication middleware that reads the body breaks verification — exclude webhook routes.

Prevention#

  • Never call req.json() in a webhook route. Make it a code-review rule.
  • Lock webhook routes out of any global body-parsing middleware. Use a matcher that skips /api/webhooks/*.
  • Use the Stripe CLI for local dev. Don't ngrok to the production endpoint or copy production secrets.
  • Treat webhook secrets as rotated quarterly. Build the rotation into your deploy process so a leak is a non-event.
  • Idempotency keys on the application side. Even with verification correct, Stripe retries on 5xx; your handler must be safe to run twice.

Frequently Asked Questions

|

Have more questions? Contact us