Verifying Stripe webhook signatures

Updated 2026-08-04

A webhook endpoint is a public URL that accepts POSTs. Without signature verification, anyone who discovers it can post whatever they like — and your code will believe it.

For a notification relay that means fake sale alerts. For anything that grants access or credits an account, it means a vulnerability.

The check

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

export async function handler(request) {
  const sig = request.headers.get("stripe-signature");
  const raw = await request.text();          // RAW bytes, not JSON

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      raw, sig, process.env.STRIPE_WEBHOOK_SECRET,
    );
  } catch (err) {
    return new Response(`invalid signature: ${err.message}`, { status: 400 });
  }

  switch (event.type) {
    case "invoice.paid":
    case "payment_intent.succeeded":
      await notify(event);
      break;
  }
  return new Response("ok", { status: 200 });
}

The mistake everyone makes

Parsing the body before verifying.

The signature is computed over the exact bytes Stripe sent. If your framework parses JSON automatically — Express's express.json() is the classic — you get an object, and re-serialising it produces different bytes: different key order, different whitespace. The signature then fails and you conclude the secret is wrong.

In Express:

app.post("/webhook",
  express.raw({ type: "application/json" }),   // NOT express.json()
  handler);

In a Worker or edge function, use request.text() and never request.json() before the check.

Where the secret comes from

Stripe → Developers → Webhooks → your endpoint → Signing secret. It starts whsec_. It is per-endpoint, so a test endpoint and a live endpoint have different secrets.

Idempotency

Stripe retries failed deliveries, so you will receive duplicates. Key on event.id and skip anything already processed — otherwise a transient timeout becomes a double notification, or worse, a double credit.

Return 200 quickly

Stripe treats a slow or failing response as a delivery failure and retries. Acknowledge first, do the work after — or at least keep the handler fast. Long processing inside the handler produces a retry storm.

Other platforms

Lemon Squeezy and Gumroad both sign webhooks too, with different header names and algorithms. Same principle: verify against the raw body before trusting anything.

If you'd rather not run any of this, FRGMNT surfaces a webhook URL in the Vault that points at a stateless relay — it forwards a push notification and stores nothing. See revenue webhooks without a server.

Frequently asked

Do I have to verify Stripe webhook signatures?

If your endpoint does anything consequential, yes. Without verification anyone who discovers the URL can post fabricated events, and your application will treat them as real.

Why does signature verification fail even with the right secret?

Almost always because the body was parsed before verification. The signature covers the exact raw bytes, so parsing to JSON and re-serialising changes them and the check fails.

Read next