Stripe Webhook Signature Verification Errors: Causes and Fixes

SendPromptly Team
3 min read

Stripe signs webhook payloads so your app can confirm the request came from Stripe and was not modified in transit. When signature verification fails, your handler should reject the request, usually with a 400. Stripe will treat that as a failed delivery and retry.

The failure is usually not mysterious. It is almost always one of four things: the body changed before verification, the endpoint secret is wrong, the event came from the wrong mode, or timestamp tolerance handling is broken.

Use the raw request body

Stripe signature verification must use the exact raw request body Stripe sent. If your framework parses JSON first, changes whitespace, changes encoding, or reserializes the body, the computed signature will not match.

In Express, use a raw body parser for the webhook route:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post("/stripe/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["stripe-signature"];

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (error) {
    console.error(`Stripe signature failed: ${error.message}`);
    return res.sendStatus(400);
  }

  console.log(`verified ${event.id} ${event.type}`);
  return res.sendStatus(200);
});

app.listen(3000);

Do not put express.json() in front of this route unless you explicitly exclude the webhook path.

Confirm the endpoint secret

Stripe signing secrets are endpoint-specific. A secret from one endpoint will not verify events sent to another endpoint. Test mode and live mode also use different webhook endpoints and secrets.

Check:

  • The endpoint URL in Stripe Dashboard
  • The signing secret for that exact endpoint
  • Whether your app is using live or test environment variables
  • Whether a recent secret rotation changed Stripe but not your deployment config

If local verification works with stripe listen but production fails, you may be using the CLI forwarding secret locally and a different dashboard endpoint secret in production. That is expected; both must be configured separately.

Watch middleware and proxies

Common framework-level causes:

  • JSON body parsing before verification
  • CSRF middleware rejecting the request before the webhook route
  • Auth middleware trying to require a user session
  • Serverless platform body transformation
  • Reverse proxy decompression or request-size limits

The webhook route should be narrow and explicit: accept the raw body, verify the signature, durably accept the event, then return a 2xx quickly.

Check timestamp tolerance

Stripe signatures include a timestamp. Most official library examples reject signatures outside a tolerance window. That helps reduce replay risk, but it also means server clock drift can break verification.

Check your production server time:

1
date -u

If the server clock is wrong, fix NTP or platform time sync. Do not solve clock drift by disabling timestamp checks globally.

Separate verification from fulfillment

Signature verification only proves the request is authentic. It does not prove your app applied the business effect. After verification, your handler still needs logs, queue monitoring, idempotency, and outcome checks.

If Stripe shows delivery failures with 400 responses, fix signature verification first. If Stripe shows 2xx but your app did not update, use the webhook delivered but nothing happened guide.

SendPromptly assumes your app verifies provider signatures before signaling receipt. It then monitors the app-side outcome that happens after verification and delivery.