VibeCPR

← All fixes

Payments · Stripe

Fix Stripe webhook "No signatures found matching the expected signature for payload"

Updated · Applies to: Stripe, Next.js, Express, Supabase Edge Functions, Vercel, Cursor, Lovable

TL;DR fix: Stripe signs the exact raw request body. If your framework parsed it as JSON first, or you're using the wrong whsec_ secret (CLI vs Dashboard, test vs live), verification fails. In the Next.js App Router use await req.text(). In Express use express.raw({ type: "application/json" }) on the webhook route. Make sure STRIPE_WEBHOOK_SECRET is the secret for that endpoint. Never skip verification to "fix" it.

Steps

  1. Check you're using the right signing secret. Each endpoint has its own whsec_ secret. stripe listen prints a different secret than the Dashboard endpoint, and test and live mode differ. Print which one your code uses (not the value itself) and compare.
  2. Pass the raw body, unmodified. constructEvent needs the exact bytes Stripe sent. Don't JSON.parse and re-stringify, and don't let middleware parse the body before your handler.
  3. Use the Stripe-Signature header. Read the stripe-signature header from the request and pass it as the second argument.
  4. Fix it for your framework. Next.js App Router: await req.text(). Express: express.raw on the webhook route only, registered before express.json(). Supabase Edge Functions (Deno): await req.text() and constructEventAsync.
  5. Redeploy and resend a test event. Update env vars in your host (Vercel, Supabase secrets), redeploy, then resend the event from the Dashboard or trigger one with stripe trigger.

Next.js App Router (app/api/stripe/webhook/route.ts)

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();                       // raw body, not req.json()
  const sig = req.headers.get("stripe-signature")!;
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response(`Webhook Error: ${(err as Error).message}`, { status: 400 });
  }
  // handle event.type ...
  return Response.json({ received: true });
}

Express

// Register BEFORE app.use(express.json())
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body, req.headers["stripe-signature"], process.env.STRIPE_WEBHOOK_SECRET);
  res.json({ received: true });
});

Supabase Edge Function (Deno)

import Stripe from "npm:stripe";
const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY")!);
const cryptoProvider = Stripe.createSubtleCryptoProvider();

const body = await req.text();
const event = await stripe.webhooks.constructEventAsync(
  body, req.headers.get("stripe-signature")!, Deno.env.get("STRIPE_WEBHOOK_SECRET")!,
  undefined, cryptoProvider);

Stripe’s docs: “The request body must be the exact UTF-8 string Stripe sent, without any modifications.”

Prompt for your AI tool

Stripe webhook fails: "No signatures found matching the expected signature for payload".
Make the webhook route verify with the RAW body (req.text() / express.raw), use the stripe-signature
header, and read STRIPE_WEBHOOK_SECRET for this endpoint. Do not remove verification. Keep the secret
key server-side only.

When to get help

Get help if webhooks drive fulfillment (orders, credits, subscriptions), if events are processed twice, or if your Stripe secret key may have been exposed. A leaked sk_live key is an emergency, so rotate it first.

FAQ

What causes "No signatures found matching the expected signature for payload"?

Either the body passed to stripe.webhooks.constructEvent is not the exact raw body Stripe sent (usually because a framework parsed it as JSON first), or the webhook signing secret (whsec_) doesn't match the endpoint that sent the event, for example using the Stripe CLI secret in production.

How do I get the raw body in the Next.js App Router?

In a route handler, call const body = await req.text() and pass that string, the stripe-signature header, and your STRIPE_WEBHOOK_SECRET to stripe.webhooks.constructEvent. Don't call req.json() first.

Can I just skip signature verification?

No. Without verification anyone can POST fake events to your endpoint, for example a fake checkout.session.completed that unlocks paid features for free.

Sources

Related fixes