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 →

The endpoint's only jobs are to prove the event is real, avoid doing it twice, and hand off the work. Anything slow happens after the 200.

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:

EventWhen it firesWhat to do
checkout.session.completedCustomer finished CheckoutLink the Stripe customer to your user; start access
invoice.paidA subscription invoice was paid, including renewalsExtend access to the new period end
invoice.payment_failedA renewal charge failedWarn the user; Stripe can retry, depending on your retry settings
customer.subscription.updatedPlan change, cancel scheduled, status changeSync plan and status
customer.subscription.deletedSubscription endedRemove 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

  1. 1
    Open Workbench > Webhooks in the Stripe Dashboard and choose Create an event destination.
  2. 2
    Select Your account, choose the payload format, and pick the event types from the table above.
  3. 3
    Choose Webhook endpoint as the destination and enter your URL, for example https://www.example.com/api/webhooks/stripe. It must be public HTTPS.
  4. 4
    Do 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 listen prints 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:

Terminal
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.completed

stripe 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:

lib/stripe-webhook.ts
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 (wiring example)
// 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)
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"]);
});
Output
ok 1 - verifies, de-duplicates and ignores unwanted events
# pass 1
# fail 0

The 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

RuleWhat Stripe doesWhat your code must do
RetriesRetries 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'
DuplicatesCan deliver the same event more than onceRecord event.id and skip repeats (unique index)
OrderingDoes not guarantee events arrive in orderDo not assume invoice.paid comes after subscription.created; fetch the object if order matters
TimeoutsTreats a slow response as a failureQueue the work; respond within seconds
RedirectsTreats a 3xx as a failureRegister 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?+
In the Stripe Dashboard, open Workbench > Webhooks, select the endpoint and click Reveal secret. It starts with whsec_. For local testing, use the secret printed by stripe listen instead.
Do I need a different webhook endpoint for test and live mode?+
You register the endpoint in each mode, and each gets its own signing secret. The URL can be the same, but your server must use the matching secret.
Why does Stripe send the same webhook twice?+
Retries after a failed or slow response, and occasional duplicate deliveries. Store each event.id you process and skip ones you have seen.
Which events do I need for subscriptions?+
Usually checkout.session.completed, invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted. Start there and add others only when you need them.
Can I test webhooks without deploying?+
Yes. Run stripe listen --forward-to localhost:3000/api/webhooks/stripe and fire events with stripe trigger.

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

← All guides