Skip to content
ExponaBLOG
PAYMENT ENGINEERING & STRIPE INTEGRATION · DAY 010 / 100

Stripe webhook signature verification: a practical guide

Make sure webhooks really come from Stripe.

Expona Admin9 min read

Webhooks are your lifeline to real-time payment events from Stripe, but they're also a potential attack surface if you don't verify them properly. A malicious actor could craft a fake webhook payload, send it to your endpoint, and trigger fraudulent refunds or unlock premium features. Stripe webhook signature verification is your guard rail—this guide walks you through implementing it correctly in production.

Why webhook verification matters

When Stripe sends a webhook to your endpoint, it includes a signature in the stripe-signature header. That signature is computed using your endpoint secret and the raw request body. If you skip verification or implement it carelessly, you're processing untrusted events as if they came directly from Stripe.

We've seen teams get caught out by signature validation bugs in staging that never surfaced until production. A customer receives a duplicate webhook, your code processes it twice, and suddenly they're issued two refunds for one chargeback. Verification isn't an afterthought—it's table stakes.

Stripe uses HMAC-SHA256 to sign payloads. The signature includes a timestamp, whichApse also validates to protect against replay attacks. When you call stripe.webhooks.constructEvent(), you're checking both the signature and the timestamp in one call. That's the right approach.

Use the raw body — Signature checks fail if the body is parsed first

The most common mistake we see is parsing the request body before verification. If your framework automatically deserializes the JSON, or if you call bodyParser.json() before your webhook handler, you lose the raw bytes needed to recompute the signature.

Here's why: the signature is a hash of the exact bytes Stripe sent. If those bytes pass through a JSON parser and are serialized again, whitespace and key ordering may differ, and the hash won't match. Even if JSON serialization is deterministic, you've already consumed the stream.

In Express, the solution is to capture the raw body before any parsing middleware touches it:

typescript
import express from 'express';
import Stripe from 'stripe';

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

// Middleware to capture raw body for webhooks
app.post(
  '/webhooks/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sig = req.headers['stripe-signature'];
    
    try {
      const event = stripe.webhooks.constructEvent(
        req.body, // Still raw Buffer here
        sig,
        endpointSecret
      );
      
      // Now safe to parse and handle
      console.log('Verified event:', event.type);
      res.json({ received: true });
    } catch (error) {
      res.status(400).send(`Webhook Error: ${error.message}`);
    }
  }
);

app.listen(3000);

Notice we use express.raw() instead of the default bodyParser.json(). This keeps req.body as a Buffer. The Stripe SDK then reconstructs the event from those raw bytes, verifies the signature, and returns a parsed JavaScript object if verification succeeds.

If you're using Next.js, you'll need to handle this differently because the default API route handlers also parse JSON:

typescript
import { buffer } from 'micro';
import type { NextApiRequest, NextApiResponse } from 'next';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_ENDPOINT_SECRET;

export const config = {
  api: {
    bodyParser: false, // Disable default parsing
  },
};

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    res.status(405).end('Method Not Allowed');
    return;
  }

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

  try {
    const event = stripe.webhooks.constructEvent(buf, sig, endpointSecret);
    console.log('Verified event:', event.type);
    res.status(200).json({ received: true });
  } catch (error) {
    res.status(400).json({ error: (error as Error).message });
  }
}

Set bodyParser: false in the config, then use the micro package's buffer() to read the raw body. This is the only safe way to preserve the bytes for signature verification.

Verify — constructEvent checks the signature and timestamp

Once you have the raw body, call stripe.webhooks.constructEvent() with three arguments: the raw body, the signature header, and your endpoint secret. The SDK handles the rest.

constructEvent() performs two checks:

  1. Signature verification: It recomputes the HMAC-SHA256 hash using the raw body and your endpoint secret, then compares it to the signed hash in the header. If they don't match, verification fails.
  2. Timestamp validation: It extracts the timestamp from the signature header and checks that it's within the last 5 minutes (by default). This protects you from replay attacks where an old, intercepted webhook is resent.

If both checks pass, constructEvent() returns a parsed event object. If either fails, it throws an error. The beauty of this design is that you don't need to write the verification logic yourself—the SDK gives you a boolean answer: "Is this real?" or "Is this fake?"

typescript
let event: Stripe.Event;

try {
  event = stripe.webhooks.constructEvent(
    rawBody,
    req.headers['stripe-signature'],
    endpointSecret
  );
} catch (error) {
  console.error('Webhook signature verification failed:', error.message);
  // Return 400 — see next section
  return res.status(400).send('Verification failed');
}

// From here on, event is trusted
switch (event.type) {
  case 'payment_intent.succeeded':
    await handlePaymentSucceeded(event.data.object);
    break;
  case 'charge.refunded':
    await handleChargeRefunded(event.data.object);
    break;
  default:
    console.log('Unhandled event type:', event.type);
}

res.json({ received: true });

The error object thrown by constructEvent() is an instance of StripeSignatureVerificationError. You can catch it specifically if you need to log it differently or track failed verification attempts in your monitoring system.

Reject failures — Return 400 when verification fails

When verification fails, return a 4xx status code (typically 400) to Stripe. Do not return 200. Stripe uses your HTTP response code to decide whether to retry the webhook.

A 2xx response tells Stripe: "I've processed this webhook." Stripe marks it as delivered and moves on. A 4xx response tells Stripe: "Something is wrong with my endpoint. Try again later." Stripe retries with exponential backoff over 3 days.

If verification fails, you should always return 400. A failed signature means either:

  • The request is not from Stripe (malicious or misconfigured).
  • Your endpoint secret is wrong or has been rotated out of sync with Stripe.

In case 1, you want to reject it loudly. In case 2, you have a configuration problem that needs to be fixed, and Stripe should retry once you've fixed it.

typescript
export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    res.status(405).end('Method Not Allowed');
    return;
  }

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

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(buf, sig, endpointSecret);
  } catch (error) {
    console.error('Webhook signature verification failed:', error.message);
    
    // Critical: Return 400 so Stripe retries
    res.status(400).json({
      error: 'Signature verification failed',
    });
    return;
  }

  // Process the verified event
  try {
    await processEvent(event);
    res.status(200).json({ received: true });
  } catch (error) {
    // Processing error — also return 400 or 500 so Stripe retries
    console.error('Event processing failed:', error);
    res.status(500).json({ error: 'Event processing failed' });
  }
}

Notice we return early with 400 if verification fails, before attempting any processing. We also return a non-2xx status if processing itself fails, so Stripe knows to retry.

Rotate secrets — Roll endpoint secrets without downtime

Over your application's lifetime, you'll need to rotate endpoint secrets. Maybe a developer left the team, or you suspect a key was leaked. Stripe makes this easy: you can have multiple secrets active at the same time.

In the Stripe Dashboard, under Webhooks, you can view all active endpoint secrets. You can create a new one while keeping the old one. Stripe will sign all outgoing webhooks using the newest secret, but both old and new secrets will pass verification—so your webhook handler won't break during the transition.

Here's how to handle multiple secrets in your code:

typescript
const endpointSecrets = [
  process.env.STRIPE_ENDPOINT_SECRET_NEW,
  process.env.STRIPE_ENDPOINT_SECRET_OLD,
];

function verifyWebhookSignature(
  rawBody: Buffer,
  sig: string,
  secrets: string[]
): Stripe.Event | null {
  for (const secret of secrets) {
    try {
      return stripe.webhooks.constructEvent(rawBody, sig, secret);
    } catch (error) {
      // Try next secret
    }
  }
  throw new Error('Webhook signature verification failed with all secrets');
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const buf = await buffer(req);
  const sig = req.headers['stripe-signature'];

  let event: Stripe.Event;

  try {
    event = verifyWebhookSignature(buf, sig, endpointSecrets);
  } catch (error) {
    res.status(400).json({ error: 'Signature verification failed' });
    return;
  }

  // Process verified event
  await processEvent(event);
  res.status(200).json({ received: true });
}

With this pattern, you can deploy a new secret to your config without downtime. For a few hours or days, both secrets will verify successfully. Once Stripe has switched to signing all webhooks with the new secret, you can remove the old one from your code and deploy again.

This dual-key approach is also useful in a multi-region or blue-green deployment scenario where different servers might be running different versions of your code temporarily.

Common mistakes

  • Parsing the body before verification: Parsing JSON before calling constructEvent() consumes the stream and changes the bytes. Always pass the raw Buffer to the SDK.
  • Ignoring timestamp validation: constructEvent() validates timestamps by default (5-minute window). Don't disable this check unless you have a very specific reason. Replay attacks are real.
  • Returning 200 on verification failure: Stripe interprets 2xx as "I processed this." Return 400 when verification fails so Stripe retries.
  • Logging the endpoint secret: Never log your endpoint secret, even in error messages. If a secret leaks, rotate it immediately.
  • Assuming all events are delivery order: Webhooks can arrive out of order. If you process payment_intent.succeeded before charge.captured, your state may be inconsistent. Use idempotency keys and database transactions to handle this.
  • Not handling network timeouts in webhook processing: If your webhook handler times out while processing, Stripe will retry. If you don't track idempotency, you'll process the same event twice. Use a database to mark events as processed.

Key takeaways

  • Always capture the raw request body before any JSON parsing middleware runs. Pass that raw Buffer to stripe.webhooks.constructEvent().
  • constructEvent() verifies both the HMAC-SHA256 signature and the timestamp in one call. Let it do the heavy lifting—don't roll your own verification.
  • Return 400 (or another 4xx code) when signature verification fails. This signals to Stripe that it should retry the webhook.
  • You can have multiple endpoint secrets active simultaneously. Use this to rotate secrets without downtime.
  • Even after verification passes, implement idempotency in your event handlers. Webhooks can be retried, and network glitches can cause duplicates.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles