Stripe Webhooks: Set Up, Verify and Test

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.

Stripe webhook essentials (checked against Stripe's docs, October 2026)
ItemDetail
Signature headerStripe-Signature: t=<timestamp>,v1=<hex HMAC SHA-256>
Signed stringtimestamp + "." + raw request body
Library default tolerance5 minutes between timestamp and now
Live mode retriesUp to three days with exponential back off
Sandbox retriesThree times over a few hours
Local testingstripe 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.

Stripe-Signature checker

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.

Handler code

Express with stripe-node

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 usual

Next.js App Router route handler

request.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 });
}

Testing with the Stripe CLI

Forward and trigger events locally

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

Events worth handling

Common Stripe event types and what to do
EventWhen it firesTypical action
checkout.session.completedA customer finished Checkout or a Payment LinkFulfil if payment_status is not unpaid
checkout.session.async_payment_succeededA delayed payment method cleared laterFulfil now
checkout.session.async_payment_failedA delayed payment failedEmail the customer
payment_intent.succeededA PaymentIntent was paidMark the order paid (custom payment flows)
invoice.paidA subscription invoice was paidExtend access for the period

The raw body rule

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().

Answer first, work later

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.

Fulfil exactly once

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.

How it works

  1. Create an event destination in Workbench (or with the API) pointing at your HTTPS URL, and copy its whsec_ signing secret.
  2. In your route, read the raw body and pass it, the Stripe-Signature header and the secret to constructEvent.
  3. Switch on event.type, queue the work, and return 200 straight away.
  4. Test locally with stripe listen --forward-to, then fire events with stripe trigger.

Tips

Frequently asked questions

Why do I get "No signatures found matching the expected signature"?

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.

Which events do I need for Stripe Checkout?

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.

Can I rely on the success page instead of a webhook?

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.

Do Stripe events arrive in order?

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.

How do I resend a failed event?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap