WritingStripe Webhook Signature Verification and Raw Body Handling — LinkIT
4 min readstripe, webhooks, security, backend, authentication

Stripe Webhook Signature Verification and Raw Body Handling

How Stripe signatures work, how to preserve the raw request body, and how to handle timestamp checks and failed verification.

A Stripe webhook receiver accepts payment-related events over HTTP. Verify the signature against the unmodified request body before using an event to change an order, subscription, or account.

How Stripe Webhook Signature Verification Works

When Stripe sends a webhook, it computes an HMAC-SHA256 signature over the raw request body using your endpoint's webhook signing secret. The signature is included in the Stripe-Signature header alongside a timestamp:

Stripe-Signature: t=1714502400,v1=abc123...,v0=legacy_signature

The t value is the Unix timestamp of when Stripe sent the request. The v1 value is the HMAC signature. Stripe's SDK reconstructs the signed payload as timestamp.raw_body, computes the HMAC with your secret, and compares it to the v1 value in the header.

A valid signature confirms that the payload matches a signature made with the endpoint secret. Protect that secret and validate the timestamp tolerance; signature verification does not remove the need to handle duplicate events.

Stripe Webhook Signature Verification: Framework Implementation

Node.js with Express

The most common mistake is using express.json() middleware before your webhook route. Express parses the JSON body into an object, and when your handler calls JSON.stringify() on that object, byte-level differences (key ordering, whitespace) break the signature.

// Correct: parse the raw body for webhook routes only
app.use(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    let event;
 
    try {
      event = stripe.webhooks.constructEvent(
        req.body,   // Buffer — raw bytes from express.raw()
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error("Signature verification failed:", err.message);
      return res.status(400).send("Webhook Error");
    }
 
    // handle event...
    res.json({ received: true });
  }
);

Register the webhook route with express.raw() before global express.json() middleware, or explicitly exclude that route from JSON parsing. Stripe signature troubleshooting.

Next.js App Router

Next.js App Router does not run Express middleware. Use req.text() to get the raw body as a string — Stripe's SDK accepts both Buffer and string.

export async function POST(req: Request) {
  const rawBody = await req.text();
  const sig = req.headers.get("stripe-signature")!;
 
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch (err) {
    return new Response("Signature verification failed", { status: 400 });
  }
 
  // handle event...
  return new Response("ok", { status: 200 });
}

POST Route Handlers are not cached by default. force-dynamic is not a fix for a webhook signature failure; check the unmodified body and signing secret first. Next.js Route Handlers.

Python with FastAPI

@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
    raw_body = await request.body()
    sig = request.headers.get("stripe-signature")
 
    try:
        event = stripe.Webhook.construct_event(
            raw_body, sig, os.environ["STRIPE_WEBHOOK_SECRET"]
        )
    except stripe.error.SignatureVerificationError:
        raise HTTPException(status_code=400, detail="Invalid signature")
 
    # handle event...
    return {"received": True}

Avoid Pydantic model parsing on the request body before this handler runs. Use await request.body() to get raw bytes.

Timestamp Tolerance and Replay Attack Prevention

Stripe's SDK checks that the timestamp in the Stripe-Signature header is within a configurable tolerance of the current time (default: 300 seconds). This prevents replay attacks — an attacker capturing a valid signed request and re-sending it later.

You can adjust the tolerance:

stripe.webhooks.constructEvent(rawBody, sig, secret, 600); // 10-minute tolerance

Do not disable the tolerance check by setting it to 0 or a very large number. If your server clock is significantly drifted, fix the NTP configuration rather than widening the window.

Testing Signature Verification Locally

Use the Stripe CLI to forward events and confirm your signature verification is working:

stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded

The CLI automatically signs forwarded events with a test secret. Confirm that a 400 response triggers when you temporarily use the wrong secret, and a 200 response triggers with the correct one.

What Happens When Verification Fails

Return 400 Bad Request on signature failure. Do not return 200 — returning success tells Stripe the event was processed, which stops retries and leaves the failure silent. A consistent stream of 400 responses from your webhook endpoint should trigger an alert in your monitoring pipeline.

Discuss payment webhook implementation with LinkIT.