Guide
Stripe webhooks in Next.js: endpoint, signing secret and events
By Aman Singh · Published · 12 min read
Stripe webhooks are how Stripe tells your app that something happened: a checkout finished, a subscription renewed, a card was declined. In a Next.js app they land on one API route, and getting that route right decides whether customers get access when they pay. This guide covers the whole setup: the endpoint, the whsec_ signing secret, which events to listen to, local testing, and the delivery rules that cause most production bugs.
Dashboard steps follow Stripe's documentation as of October 2026, where webhooks live in Workbench. The handler code was run with the stripe Node.js library 23.0.0 against signed test events.
Seeing "No signatures found matching the expected signature"? Fix signature verification →
What webhooks do, and which events matter
Anything that happens after the customer leaves your page arrives as a webhook: renewals, failed payments, cancellations from the customer portal, disputes. Never grant access based on a redirect back to your site; grant it when the event arrives. For a typical subscription product these are the events worth handling:
| Event | When it fires | What to do |
|---|---|---|
checkout.session.completed | Customer finished Checkout | Link the Stripe customer to your user; start access |
invoice.paid | A subscription invoice was paid, including renewals | Extend access to the new period end |
invoice.payment_failed | A renewal charge failed | Warn the user; Stripe can retry, depending on your retry settings |
customer.subscription.updated | Plan change, cancel scheduled, status change | Sync plan and status |
customer.subscription.deleted | Subscription ended | Remove access |
For one-time payments, checkout.session.completed is usually enough. Subscribe to nothing else until you need it: every extra event is load on your endpoint.
Step 1: create the endpoint
- 1Open Workbench > Webhooks in the Stripe Dashboard and choose Create an event destination.
- 2Select Your account, choose the payload format, and pick the event types from the table above.
- 3Choose Webhook endpoint as the destination and enter your URL, for example
https://www.example.com/api/webhooks/stripe. It must be public HTTPS. - 4Do it once in test mode for development and again in live mode for production.
Step 2: the signing secret
After you create the endpoint, its settings page shows a signing secret starting with whsec_; click Reveal secret to copy it. Put it in STRIPE_WEBHOOK_SECRET, never in code. Three things trip people up:
- One secret per endpoint and mode. The same URL in test and live mode has two different secrets.
- The CLI has its own.
stripe listenprints a separate secret for local forwarding; use that one on your machine. - Rolling. You can roll a secret from the endpoint's menu and keep the old one valid for up to 24 hours while you deploy the new one.
A wrong secret produces the same error as a modified body. If verification fails, the signature verification guide shows how to tell the causes apart.
Step 3: test locally with the Stripe CLI
You do not need a public URL during development. The CLI forwards events from your test account to localhost:
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# prints: Ready! Your webhook signing secret is 'whsec_...'
# in another terminal, fire a test event:
stripe trigger checkout.session.completedstripe trigger creates real test objects, so the event your route receives is the same shape production will send.
Step 4: a handler that survives real traffic
A webhook route has three jobs: prove the event is genuine, make sure it is processed once, and answer quickly. This handler does exactly that and passes everything else to a queue:
import Stripe from "stripe";
// A Stripe webhook handler for the Next.js App Router that verifies the
// signature, ignores events you did not ask for, skips duplicates, and answers
// quickly. Slow work goes to `enqueue` (a queue, background job, etc.).
const HANDLED = new Set([
"checkout.session.completed",
"customer.subscription.updated",
"customer.subscription.deleted",
"invoice.paid",
"invoice.payment_failed",
]);
export type EventStore = {
/** Records the event ID; returns false if it was already recorded. Must be atomic. */
markProcessed(eventId: string): Promise<boolean>;
};
export function createStripeWebhook(opts: {
stripe: Stripe;
signingSecret: string;
store: EventStore;
enqueue: (event: Stripe.Event) => Promise<void>;
}) {
return async function POST(req: Request): Promise<Response> {
const signature = req.headers.get("stripe-signature");
if (!signature) return new Response("Missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = opts.stripe.webhooks.constructEvent(await req.text(), signature, opts.signingSecret);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (!HANDLED.has(event.type)) {
return new Response("Ignored", { status: 200 });
}
if (!(await opts.store.markProcessed(event.id))) {
return new Response("Duplicate", { status: 200 });
}
await opts.enqueue(event); // fast: just hand it off
return new Response("ok", { status: 200 });
};
}// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
import { createStripeWebhook } from "@/lib/stripe-webhook";
import { db, queue } from "@/lib/infra"; // your database and job queue
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export const POST = createStripeWebhook({
stripe,
signingSecret: process.env.STRIPE_WEBHOOK_SECRET!,
store: {
// A unique index on event_id makes this atomic.
markProcessed: async (id) => (await db.insertIgnore("stripe_events", { event_id: id })) === 1,
},
enqueue: (event) => queue.add("stripe-event", { id: event.id, type: event.type }),
});The test signs real payloads with Stripe's library and sends them through the handler:
Show the test (stripe-webhook.test.ts)
import { test } from "node:test";
import assert from "node:assert/strict";
import Stripe from "stripe";
import { createStripeWebhook } from "./stripe-webhook.ts";
const stripe = new Stripe("sk_test_dummy");
const secret = "whsec_test_secret";
function signedRequest(event: object) {
const payload = JSON.stringify(event, null, 2);
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
return new Request("http://localhost/api/webhooks/stripe", {
method: "POST",
headers: { "stripe-signature": header, "content-type": "application/json" },
body: payload,
});
}
const event = (id: string, type: string) => ({ id, object: "event", type, data: { object: { id: "obj_1" } } });
test("verifies, de-duplicates and ignores unwanted events", async () => {
const seen = new Set<string>();
const queued: string[] = [];
const POST = createStripeWebhook({
stripe,
signingSecret: secret,
store: { markProcessed: async (id) => (seen.has(id) ? false : (seen.add(id), true)) },
enqueue: async (e) => void queued.push(`${e.id}:${e.type}`),
});
const first = await POST(signedRequest(event("evt_1", "invoice.paid")));
const again = await POST(signedRequest(event("evt_1", "invoice.paid")));
const other = await POST(signedRequest(event("evt_2", "customer.created")));
const forged = await POST(
new Request("http://localhost", { method: "POST", headers: { "stripe-signature": "t=1,v1=bad" }, body: "{}" }),
);
assert.deepEqual(
[first.status, await first.text(), again.status, await again.text(), other.status, await other.text(), forged.status],
[200, "ok", 200, "Duplicate", 200, "Ignored", 400],
);
assert.deepEqual(queued, ["evt_1:invoice.paid"]);
});ok 1 - verifies, de-duplicates and ignores unwanted events
# pass 1
# fail 0The same event delivered twice is queued once, an event type you did not ask for gets a 200 and is ignored, and a forged request gets a 400.
Retries, duplicates and ordering
| Rule | What Stripe does | What your code must do |
|---|---|---|
| Retries | Retries failed deliveries for up to three days in live mode (a few times over hours in a sandbox) | Return 2xx quickly; treat any non-2xx as 'please resend' |
| Duplicates | Can deliver the same event more than once | Record event.id and skip repeats (unique index) |
| Ordering | Does not guarantee events arrive in order | Do not assume invoice.paid comes after subscription.created; fetch the object if order matters |
| Timeouts | Treats a slow response as a failure | Queue the work; respond within seconds |
| Redirects | Treats a 3xx as a failure | Register the final URL, including www or trailing slash |
Debugging failed deliveries
Open the endpoint in Workbench > Webhooks and select Event deliveries to see each attempt and the status your server returned. The common ones:
- 400: usually signature verification. Check the secret and that you read the raw body.
- 404 or 405: wrong URL, or the route does not export
POST. - 3xx: the URL redirects (http to https, apex to www). Register the final URL.
- 500 or timeout: your code threw or took too long. Move work to a queue.
You can resend an event from the Dashboard (up to 15 days after it was created) or with stripe events resend in the CLI (up to 30 days).
Common questions
Where do I find my Stripe webhook secret?+
Do I need a different webhook endpoint for test and live mode?+
Why does Stripe send the same webhook twice?+
Which events do I need for subscriptions?+
Can I test webhooks without deploying?+
Want Stripe set up properly?
Checkout, subscriptions and webhooks that hold up under retries and renewals are what Stripe integration covers. Choosing a checkout style first? See Stripe hosted vs embedded Checkout.
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call