Testing Stripe webhooks in a local development environment is a necessary step before handling real credit card transactions in production. According to Stripe's developer documentation, webhook signatures are generated using an HMAC-SHA256 hash over the combined timestamp and the exact raw byte payload, with a strict 300-second (5-minute) tolerance window to prevent replay attacks.
If your local server alters a single byte of whitespace or reformats JSON before verification, Stripe's SDK will throw a SignatureVerificationError. This guide walks through an end-to-end workflow to reliably capture, verify, and replay Stripe webhook events locally.
- Capture the
Stripe-Signatureheader together with the exact raw request bytes. - Avoid standard JSON body parsers before signature verification to prevent byte alteration.
- Stripe enforces a 300-second timestamp tolerance; replay scripts must use fresh timestamps or mock clocks.
- Test both positive paths (
checkout.session.completed) and failure flows (invoice.payment_failed).
The Stripe Signature Verification Pipeline
Understanding how Stripe computes signatures makes debugging local validation failures straightforward. Stripe transmits a Stripe-Signature header containing a UNIX timestamp (t=...) and one or more signature hashes (v1=...).
Cryptographic verification pipeline required by Stripe to authenticate incoming events.
Handling Raw Bodies in Modern Frameworks
The number one cause of local Stripe webhook verification errors is body parser middleware. Frameworks often convert JSON automatically, which alters formatting.
1. TanStack Start / Modern Fetch Handlers
In modern web standards, fetch requests provide direct access to the stream before JSON conversion:
import { Stripe } from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
});
export async function POST(request: Request) {
const signature = request.headers.get('stripe-signature');
if (!signature) {
return new Response('Missing stripe-signature header', { status: 400 });
}
// Preserve the raw string directly from the HTTP stream
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.error(`Webhook verification failed: ${message}`);
return new Response(`Webhook Error: ${message}`, { status: 400 });
}
// Handle verified events
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await fulfillOrder(session.id);
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscriptionStatus(subscription);
break;
}
}
return new Response(JSON.stringify({ received: true }), { status: 200 });
}2. Express.js Raw Body Setup
In Express, ensure express.raw() handles the webhook route before global express.json() is registered:
import express from 'express';
import Stripe from 'stripe';
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
// CRITICAL: Mount raw parser specifically for the Stripe route
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['stripe-signature'];
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer from express.raw
sig as string,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Invalid signature';
return res.status(400).send(`Webhook Error: ${message}`);
}
res.json({ received: true });
}
);
// Global JSON parser mounted after webhook route
app.use(express.json());Capturing and Inspecting Stripe Events with Probe
While developing complex checkout flows, triggering live webhooks from the Stripe Dashboard to a local server can be tedious. Using Probe, you can create a dedicated endpoint in seconds:
- Create a new test endpoint in your Probe workspace.
- In the Stripe Dashboard > Developers > Webhooks, add the Probe endpoint URL (e.g.
https://probe.monobase.io.vn/hooks/ep_dev_stripe). - Select events you wish to test, such as
checkout.session.completedandpayment_intent.payment_failed. - Trigger a test event using Stripe's "Send test webhook" button.
- In Probe, inspect the exact headers, query parameters, timestamp, and JSON body payload.
- Copy the captured payload into your test fixtures to run repeatable automated integration tests.
4 Critical Scenarios to Test Before Production
A robust Stripe webhook handler must handle edge cases gracefully:
- Successful Checkout (
checkout.session.completed): Verify that customer orders are provisioned and order records are saved in your database. - Payment Failure (
invoice.payment_failed): Ensure user accounts are notified and subscription grace periods are properly marked. - Duplicate Events: Stripe guarantees at-least-once delivery. Re-sending the exact same event ID must not double-fulfill an order.
- Out-of-Order Events: If
customer.subscription.deletedarrives beforecustomer.subscription.updateddue to network routing, your state machine should retain the final deleted state.
