Next.js route handlers and Stripe webhooks
A storefront built on Next.js receives Stripe’s checkout.session.completed
webhook in a route handler, verifies it, and marks the order paid. Stripe
delivers again until it gets a 2xx, so one order can see several deliveries.
The complete code, which this repository type-checks against the SDK, is in
examples/recipes/nextjs-stripe.
What to instrument, and where
Section titled “What to instrument, and where”| Step | Where | Call | Operation recorded |
|---|---|---|---|
| The verified webhook arrives | route handler | journeyIdFor, continueJourney, record |
received |
| Stripe’s ids are known | route handler | identify |
identified |
| The order is marked paid | route handler | persist |
persisted |
| The handler is done | route handler | finish |
completed |
| Events leave the function | route handler | after(() => recorder.flush()) |
nothing |
The entity is your order id, which the checkout session carries as
client_reference_id. Stripe’s session and customer ids become aliases, so a
search for either finds the order’s journey.
The recorder
Section titled “The recorder”Create it once per server process. next dev reloads modules on every edit, so
the recorder is kept on globalThis rather than created again each time.
const cache = globalThis as typeof globalThis & { wayscribe?: Recorder };
export const recorder: Recorder = (cache.wayscribe ??= createRecorder({ endpoint: process.env.WAYSCRIBE_URL ?? "http://localhost:8080", apiKey: process.env.WAYSCRIBE_API_KEY ?? "", serviceName: "storefront", environment: process.env.WAYSCRIBE_ENVIRONMENT ?? "development", // Stripe's checkout session carries the buyer's details. redact: ["**.customer_details", "**.email"], // Experimental: at least 32 bytes, kept like any other credential. journeyIdSecret: process.env.JOURNEY_ID_SECRET, logDiagnostics: process.env.NODE_ENV !== "production"}));journeyIdSecret is what lets every delivery for one order land on one journey
(below). Without it, journeyIdFor returns a random id each time, reports a
configuration_error, and prints one warning line per process.
The route handler
Section titled “The route handler”The SDK is written for Node (22.12 or later), so the handler declares the Node.js runtime rather than the Edge runtime:
export const runtime = "nodejs";Verify first. An unverified body can name any order, so nothing is recorded for it:
const raw = await request.text(); let event: Stripe.Event; try { event = stripe.webhooks.constructEvent( raw, request.headers.get("stripe-signature") ?? "", process.env.STRIPE_WEBHOOK_SECRET ?? "" ); } catch { // Unverified: nothing in it can be trusted to name a record. return new Response("invalid signature", { status: 400 }); }Then join the order’s journey, and make sure the events leave:
const entity = { type: "order", id: orderId }; const journey = recorder.continueJourney({ journeyId: recorder.journeyIdFor(entity), entity, // Experimental. Public text: no ids, no names, no email addresses. label: `Checkout ${session.payment_status} · ${formatAmount(session.amount_total, session.currency)}` });
// A serverless function can be frozen once it has answered. `after` runs // once the response is sent, success or not, so the events still leave. after(() => recorder.flush());journeyIdForandlabelare experimental. The id is derived from the entity under your secret, so it is the same on every delivery, every instance, and every deploy, and nobody without the secret can compute it. Each delivery adds its ownreceivedstep to that one journey, which is what happened.- Flush before the function is frozen. The SDK sends in the background, in
batches, about once a second. On a serverless platform the function may be
suspended as soon as it has answered, with the batch still queued.
after(fromnext/server, stable since Next.js 15.1) runs once the response is finished, andrecorder.flush()sends what is queued. On a long-runningnext startserver the flush is harmless. - The label names the payment state and the amount. It is stored and shown in plain text, so it carries no order id, no customer id, and nothing about the buyer.
Record what arrived:
// The Stripe-Signature header is on the built-in redaction list, so it is // stored as [REDACTED]; the signature and the body together would be a // request your endpoint accepts. journey.record({ operation: "received", name: "receive-stripe-webhook", input: { headers: request.headers, event }, metadata: { stripeEventId: event.id } });Stripe’s signature header is redacted by default. stripe-signature is on
the built-in list, with the webhook signature headers of GitHub, Slack, HubSpot,
Twilio and Shopify, so you do not add a rule for it. A Headers object is
stored as a plain object, and the redaction applies inside it. Stored, the
input above reads:
{ "headers": { "content-type": "application/json", "stripe-signature": "[REDACTED]" }, "event": { "id": "evt_1", "data": { "object": { "customer_details": "[REDACTED]" } } }}That was taken from a local stack on 2026-09-16 with this recorder’s configuration, trimmed
to the fields shown. **.customer_details replaces the buyer’s name, email and
address as one value; drop it from redact only if the environment’s capture
mode already keeps payloads out.
The rest of the handler identifies the journey by Stripe’s ids and records the write:
const customerId = typeof session.customer === "string" ? session.customer : session.customer?.id; // Aliases are masked when read; these are customer identifiers. journey.identify({ stripeCheckoutSession: session.id, ...(customerId === undefined ? {} : { stripeCustomer: customerId }) });
try { await journey.persist("mark-order-paid", { orderId, checkoutSessionId: session.id }, () => markOrderPaid(orderId, session.id) ); } catch { // Recorded by persist. A 500 makes Stripe deliver it again. return new Response("could not record payment", { status: 500 }); }
journey.finish(); return new Response(null, { status: 200 });persist rethrows the exact error your write threw, after recording the step
with it, so the handler answers 500 and Stripe delivers again. The next
delivery lands on the same journey, and the timeline shows the failed write,
then the one that worked.
What you will see
Section titled “What you will see”Search for an order id, or paste a Stripe checkout session id. The timeline
shows one received, identified and persisted for each delivery, the
failed ones marked, and completed at the end. The Journeys page lists the
journey under its label, such as Checkout paid · 19.99 USD.