A Stripe webhook is an HTTPS endpoint on your server that Stripe POSTs event JSON to, such as checkout.session.completed after a payment. Your handler must verify the Stripe-Signature header against the untouched raw body using the endpoint's whsec_ secret, return a 2xx quickly, and tolerate the same event arriving more than once.
| Item | Detail |
|---|---|
| Signature header | Stripe-Signature: t=<timestamp>,v1=<hex HMAC SHA-256> |
| Signed string | timestamp + "." + raw request body |
| Library default tolerance | 5 minutes between timestamp and now |
| Live mode retries | Up to three days with exponential back off |
| Sandbox retries | Three times over a few hours |
| Local testing | stripe listen --forward-to localhost:4242/webhook |
Updated
Most broken Stripe integrations fail in one of three places: the framework parsed the body before verification, the handler did slow work and timed out, or a retried event fulfilled an order twice. This guide walks through a handler that avoids all three, with a checker below that verifies a Stripe-Signature header in your browser so you can see exactly what the library computes.
Paste a Stripe-Signature header, the raw body and your whsec_ secret to see the same check the libraries run. It runs in your browser with Web Crypto and sends nothing. Use a test-mode secret, never a live one.
express.raw is mounted on this route only, so the body reaches constructEvent as the original bytes.
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_...
const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.get("stripe-signature"), endpointSecret);
} catch (err) {
return res.status(400).send("Webhook Error: " + err.message);
}
switch (event.type) {
case "checkout.session.completed":
case "checkout.session.async_payment_succeeded":
jobs.add("fulfill", { sessionId: event.data.object.id, eventId: event.id });
break;
case "checkout.session.async_payment_failed":
jobs.add("notify-failed", { sessionId: event.data.object.id });
break;
}
res.sendStatus(200);
});
app.use(express.json()); // other routes can parse JSON as usualrequest.text() gives the unparsed body. Throwing inside constructEvent means the signature or secret is wrong.
// app/api/stripe/webhook/route.js
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const body = await request.text();
const sig = request.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response("Bad signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
await enqueueFulfilment(event.data.object.id);
}
return new Response(null, { status: 200 });
}listen prints a whsec_ secret for this session. Put it in your local env, then trigger events in a second terminal.
stripe login
stripe listen --forward-to localhost:4242/webhook
# Ready! Your webhook signing secret is 'whsec_...'
# in another terminal
stripe trigger payment_intent.succeeded
# resend a real event to a registered endpoint
stripe events resend evt_123 --webhook-endpoint=we_123| Event | When it fires | Typical action |
|---|---|---|
| checkout.session.completed | A customer finished Checkout or a Payment Link | Fulfil if payment_status is not unpaid |
| checkout.session.async_payment_succeeded | A delayed payment method cleared later | Fulfil now |
| checkout.session.async_payment_failed | A delayed payment failed | Email the customer |
| payment_intent.succeeded | A PaymentIntent was paid | Mark the order paid (custom payment flows) |
| invoice.paid | A subscription invoice was paid | Extend access for the period |
Stripe computes an HMAC SHA-256 over the timestamp, a dot and the exact bytes it sent. If anything parses that JSON and turns it back into a string, whitespace or key order can shift and the signature no longer matches.
In Express that means express.raw on this one route, placed before any global JSON parser runs. In a Next.js App Router handler, call await request.text() and pass the string; never request.json().
Stripe waits only a short while for a response, and with a Checkout success_url it holds the customer for up to 10 seconds waiting on your reply to checkout.session.completed. Save the event id, push the job to a queue, return 200, then send emails and update records in the background.
Your fulfilment function may run several times for one Checkout Session, sometimes at the same moment, from a retry plus the success page. Record a fulfilled flag keyed by session id inside a transaction, and skip the work when it is already set.
Almost always because the body was changed before verification, for example by express.json() or a body parser that re-encodes JSON. Mount express.raw({ type: "application/json" }) on the webhook route only, or read request.text() in a Next.js route handler. A wrong secret causes the same message.
checkout.session.completed for card payments, plus checkout.session.async_payment_succeeded for delayed methods such as bank debits. Stripe also suggests handling checkout.session.async_payment_failed so you can tell the customer. Check the session's payment_status before fulfilling.
No. Stripe's own docs say a customer may pay and close the tab before your success page loads. Use the success page for a fast confirmation if you like, but the webhook must also trigger fulfilment, and both paths must be safe to run twice.
Stripe does not guarantee order. invoice.paid can arrive before customer.subscription.created. Track event ids to spot duplicates and fetch the related object from the API when you need its latest state.
Click Resend on the event in the Dashboard (works up to 15 days after creation) or run stripe events resend <event_id> --webhook-endpoint=<endpoint_id> with the CLI (up to 30 days). Automatic retries continue regardless.
Related: Stripe Payment Link guide, Webhook vs API, Next.js API routes, HTTP status codes, Discord webhook guide.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.