<!-- APIScout AI-readable guide source -->
<!-- Canonical: https://apiscout.dev/guides/stripe-webhooks-complete-guide-2026 -->
<!-- Raw Markdown: https://apiscout.dev/guides/stripe-webhooks-complete-guide-2026/raw.md -->
<!-- Source path: content/guides/stripe-webhooks-complete-guide-2026.mdx -->

---
og_image: "/images/guides/stripe-webhooks-complete-guide-2026.webp"
title: "Stripe Webhooks 2026: Setup and Best Practices"
description: "Stripe webhooks in production: signature verification, idempotency, retry handling, Stripe CLI testing, and the production gotchas that trip every team."
date: "2026-03-08"
author: "APIScout Team"
tags: ["stripe", "webhooks", "payments", "next-js", "idempotency", "2026"]
tier: 1
---

## TL;DR

**Stripe webhooks are how your backend learns about payment events.** When a customer subscribes, pays, or cancels — Stripe doesn't wait for you to ask; it pushes the event to your endpoint. The implementation is straightforward, but production reliability requires signature verification (security), idempotency (no double-processing), graceful retry handling (Stripe retries for 72 hours), and testing without real payments. This guide covers all of it.

## Key Takeaways

- **Always verify webhook signatures** — skip this and anyone can send fake payment events to your endpoint
- **Return 200 immediately** — process the event asynchronously; Stripe retries if you don't respond within 30s
- **Idempotency is required** — Stripe can deliver the same event multiple times; your handler must be safe to call twice
- **Local testing**: Stripe CLI `stripe listen` forwards live events to localhost
- **Critical events**: `checkout.session.completed`, `customer.subscription.updated`, `invoice.payment_failed`
- **Webhook retry window**: 72 hours, exponential backoff

---

## The Webhook Architecture

```
User action → Stripe processes → Stripe sends webhook → Your endpoint
                                         ↓
                                 Returns 200 immediately
                                         ↓
                                 Process event async
                                         ↓
                                 Update your database
```

Stripe events you must handle for a subscription SaaS:

```
checkout.session.completed      → New subscription started
customer.subscription.updated   → Plan change, renewal, reactivation
customer.subscription.deleted   → Subscription cancelled/expired
invoice.payment_succeeded       → Successful payment (recurring)
invoice.payment_failed          → Failed payment (dunning started)
customer.subscription.trial_will_end → Trial ending in 3 days
payment_method.attached         → New payment method added
```

---

## Setup: Next.js App Router

```typescript
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text();  // Raw text — must NOT be parsed first
  const headersList = await headers();
  const sig = headersList.get('stripe-signature');

  if (!sig) {
    return new Response('No signature', { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // Verify signature — throws if invalid:
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return new Response('Webhook signature verification failed', { status: 400 });
  }

  // Return 200 immediately — process async:
  handleWebhookEvent(event).catch((err) => {
    console.error('Webhook processing failed:', err, { eventId: event.id, type: event.type });
  });

  return new Response('OK', { status: 200 });
}

async function handleWebhookEvent(event: Stripe.Event) {
  // Check idempotency — skip if already processed:
  const already = await db.stripeEvent.findUnique({ where: { stripeEventId: event.id } });
  if (already) {
    console.log(`Skipping duplicate event: ${event.id}`);
    return;
  }

  // Process the event:
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
      break;
    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object as Stripe.Invoice);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  // Mark event as processed:
  await db.stripeEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      processedAt: new Date(),
    },
  });
}
```

---

## Idempotency: Handle Duplicate Events

Stripe guarantees "at least once" delivery — the same event can arrive twice. Your handlers must be safe to run multiple times with the same input.

```typescript
// WRONG — not idempotent:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  await db.user.update({
    where: { stripeCustomerId: session.customer as string },
    data: { plan: 'pro' },
  });
  await sendWelcomeEmail(user.email);  // Will send twice if event delivered twice!
}

// RIGHT — idempotent:
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const customerId = session.customer as string;

  // Use upsert instead of create/update:
  const subscription = await stripe.subscriptions.retrieve(session.subscription as string);

  const result = await db.subscription.upsert({
    where: { stripeCustomerId: customerId },
    create: {
      stripeCustomerId: customerId,
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
    update: {
      stripeSubscriptionId: subscription.id,
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });

  // Only send email if this is the FIRST time we're processing:
  // (check if subscription was just created, not updated)
  if (result._count?.create === 1) {  // Prisma 5+ returns create count
    await sendWelcomeEmail(customerId);
  }
}
```

```typescript
// Schema for idempotency table:
// Prisma:
model StripeEvent {
  id            String   @id @default(cuid())
  stripeEventId String   @unique
  type          String
  processedAt   DateTime @default(now())

  @@index([stripeEventId])
}
```

---

## The Critical Handlers

```typescript
// checkout.session.completed — new subscription
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  if (session.mode !== 'subscription') return;  // Ignore one-time payments

  const subscription = await stripe.subscriptions.retrieve(
    session.subscription as string
  );
  const priceId = subscription.items.data[0].price.id;

  // Map Stripe price to your plan:
  const planMap: Record<string, string> = {
    [process.env.STRIPE_PRO_MONTHLY_PRICE_ID!]: 'pro',
    [process.env.STRIPE_PRO_ANNUAL_PRICE_ID!]: 'pro',
    [process.env.STRIPE_TEAM_MONTHLY_PRICE_ID!]: 'team',
  };

  const plan = planMap[priceId] ?? 'free';

  await db.user.update({
    where: { stripeCustomerId: session.customer as string },
    data: {
      plan,
      stripeSubscriptionId: subscription.id,
      subscriptionStatus: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}

// customer.subscription.updated — renewals, upgrades, cancellations
async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  const priceId = subscription.items.data[0].price.id;

  const planMap: Record<string, string> = {
    [process.env.STRIPE_PRO_MONTHLY_PRICE_ID!]: 'pro',
    [process.env.STRIPE_TEAM_MONTHLY_PRICE_ID!]: 'team',
  };

  await db.user.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      plan: planMap[priceId] ?? 'free',
      subscriptionStatus: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      // cancelAtPeriodEnd: true means they've cancelled but still have access
      cancelledAt: subscription.cancel_at_period_end
        ? new Date(subscription.current_period_end * 1000)
        : null,
    },
  });
}

// customer.subscription.deleted — access should be revoked
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  await db.user.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      plan: 'free',
      subscriptionStatus: 'canceled',
      stripeSubscriptionId: null,
    },
  });
}

// invoice.payment_failed — send dunning email
async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const user = await db.user.findUnique({
    where: { stripeCustomerId: invoice.customer as string },
  });
  if (!user) return;

  // Stripe automatically retries — we just need to notify the user:
  await sendEmail({
    to: user.email,
    subject: 'Action required: payment failed',
    template: 'payment-failed',
    data: {
      amount: (invoice.amount_due / 100).toFixed(2),
      currency: invoice.currency.toUpperCase(),
      retryUrl: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
    },
  });
}
```

---

## Local Testing with Stripe CLI

```bash
# Install Stripe CLI:
brew install stripe/stripe-cli/stripe
stripe login

# Forward events to your local server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Output:
# > Ready! Your webhook signing secret is: whsec_test_... (copy this to .env)

# Trigger specific events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed

# Or replay a real production event:
stripe events resend evt_1234567890
```

```env
# .env.local — use the CLI-generated secret for local testing:
STRIPE_WEBHOOK_SECRET=whsec_test_abc123...  # From stripe listen output

# .env.production — use Stripe Dashboard webhook secret for production:
STRIPE_WEBHOOK_SECRET=whsec_live_xyz789...  # From Stripe Dashboard
```

---

## Registering the Webhook in Production

```typescript
// Programmatic webhook registration (optional — usually done in Stripe Dashboard):
const webhook = await stripe.webhookEndpoints.create({
  url: 'https://yourdomain.com/api/webhooks/stripe',
  enabled_events: [
    'checkout.session.completed',
    'customer.subscription.updated',
    'customer.subscription.deleted',
    'invoice.payment_succeeded',
    'invoice.payment_failed',
    'customer.subscription.trial_will_end',
  ],
});

console.log('Webhook secret:', webhook.secret);
// Save this to STRIPE_WEBHOOK_SECRET in your environment
```

---

## Debugging Webhook Issues

```typescript
// Add detailed logging:
async function handleWebhookEvent(event: Stripe.Event) {
  const startTime = Date.now();
  console.log(`[Webhook] Processing: ${event.type} (${event.id})`);

  try {
    // ... your event handling

    const duration = Date.now() - startTime;
    console.log(`[Webhook] Completed: ${event.type} in ${duration}ms`);
  } catch (err) {
    console.error(`[Webhook] Failed: ${event.type}`, {
      eventId: event.id,
      error: err instanceof Error ? err.message : err,
      duration: Date.now() - startTime,
    });
    throw err;  // Rethrow so it gets logged
  }
}
```

```bash
# View webhook delivery attempts in Stripe Dashboard:
# Dashboard → Developers → Webhooks → [your endpoint] → Recent deliveries

# Or via CLI:
stripe events list --limit 20
stripe events retrieve evt_1234567890
```

---

## Production Gotchas

Issues that trip teams in production:

**The 30-second rule**: Stripe expects a 200 response within 30 seconds. If your `handleWebhookEvent` takes longer (slow database write, external API call), Stripe marks the delivery as failed and retries. The pattern in this guide — return 200 immediately, process async — solves this. But make sure your async processing doesn't silently fail. Log errors explicitly; if the async handler throws, Stripe won't know and won't retry.

**Ordering is not guaranteed**: Stripe's retry logic can deliver events out of order. A `customer.subscription.deleted` event can arrive before `customer.subscription.updated`. Design your handlers to be order-independent: always fetch the latest subscription state from Stripe API rather than relying on event payload alone. `const subscription = await stripe.subscriptions.retrieve(event.data.object.id)` ensures you always have current state.

**Test vs production webhook secrets**: your `STRIPE_WEBHOOK_SECRET` from `stripe listen` (test) is different from the secret in Stripe Dashboard (production). Switching environments and forgetting to update this env var causes `Webhook signature verification failed` in production. Use separate `.env.local` (test) and production secrets.

**Webhook endpoint availability**: your webhook endpoint must be publicly reachable before you go live. If you deploy to Vercel, the `/api/webhooks/stripe` route is available immediately on deployment. If you use a VPS, make sure the domain and SSL are configured before registering the webhook URL in Stripe Dashboard.

---

## Monitoring Webhook Health

Stripe Dashboard → Developers → Webhooks shows delivery attempt history for each endpoint. Events with failed deliveries appear in red with the HTTP status code your endpoint returned. Check this daily when you first launch; weekly after the system stabilizes.

For automated monitoring: create a Datadog/Grafana alert on HTTP 500 responses to `/api/webhooks/stripe`. Stripe retries on 5xx — so a spike in errors from your webhook endpoint shows up as repeated delivery attempts. Track key metrics: webhook delivery success rate (should be >99%), processing time per event type (should be <500ms for database writes), and event lag (time from Stripe generation to your processing — `event.created` vs `Date.now()`). Stripe retries for 72 hours with exponential backoff — if your endpoint is down for more than 3 days, events are permanently lost, putting your subscription state out of sync.

---

## Webhook Testing in CI/CD

The Stripe CLI's `stripe listen` command works locally but can't run in cloud CI without a publicly reachable URL. Three approaches for testing webhook handling in CI:

Local forwarding with ngrok or Cloudflare Tunnel: run your app server in one process and forward Stripe's webhooks through a tunnel in another. GitHub Actions can run both — start the Next.js dev server, start ngrok, register the tunnel URL as a Stripe webhook endpoint for the test run, and tear down after. This is the closest to production behavior and tests the full path: Stripe → internet → your endpoint. The downside is test isolation: each CI run needs a unique tunnel URL, and `stripe trigger` in test mode still requires valid test-mode credentials.

Mocked webhook handler tests: skip the HTTP layer entirely and call your `handleWebhookEvent` function directly with mocked `Stripe.Event` objects. This is the fastest approach for testing event handler logic — create the event object as JavaScript, call the handler, assert the database changes. The limitation is that you're not testing the signature verification step, the raw body parsing, or the 200-response-before-async-processing pattern. Use these for unit testing individual handler functions; don't rely on them as the only test of your webhook endpoint.

Docker Compose integration tests: run a local server, a test database, and the Stripe CLI in Docker Compose for a fully isolated test environment. The Stripe CLI in `stripe listen` mode can forward to your containerized server without an internet connection by running in `--forward-to` mode against the container's internal network address. This approach takes more setup but produces reliable, isolated webhook tests that work identically in any environment and don't require ngrok accounts or tunnel configuration.

## Designing Event-Driven State Machines

Stripe subscription webhooks map cleanly to a state machine. Modeling your subscription state explicitly prevents the common bug where state transitions are handled inconsistently across multiple event types.

The subscription states your application needs to handle:

```
free       → trialing  (checkout.session.completed with trial_period_days)
trialing   → active    (customer.subscription.updated when trial converts to paid)
trialing   → canceled  (customer.subscription.deleted if user cancels during trial)
free       → active    (checkout.session.completed without trial)
active     → past_due  (customer.subscription.updated when payment fails)
past_due   → active    (customer.subscription.updated on successful retry)
past_due   → canceled  (customer.subscription.deleted after all retries exhausted)
active     → canceled  (customer.subscription.deleted on user cancellation)
active     → active    (customer.subscription.updated on plan change or renewal)
```

The critical design decision: never derive subscription state from the event type alone. Derive it from the subscription object's fields. When you receive `customer.subscription.updated`, retrieve the subscription from Stripe (don't trust the event payload alone — see the ordering caveat in Production Gotchas) and then read `subscription.status` and `subscription.items.data[0].price.id` to determine what to write to your database.

This is especially important for the `active → canceled` transition. A user who cancels with `cancel_at_period_end: true` doesn't lose access immediately — they're still active until `current_period_end`. The `customer.subscription.updated` webhook fires when they cancel, with `status: 'active'` and `cancel_at_period_end: true`. The `customer.subscription.deleted` webhook fires at the period end when access should be revoked. If you revoke access on `cancel_at_period_end: true` instead of waiting for the deleted event, you've broken the user's paid access for the remainder of their billing cycle — a support headache and a potential chargeback.

Building the state machine as an explicit table (previous state + event → new state) makes these transitions documentable, testable, and auditable. Run the state table through unit tests with mocked Stripe event objects before deploying subscription logic changes.

## Webhook Security Beyond Signature Verification

Signature verification stops fake webhook events, but it's not the only security consideration for your webhook endpoint.

IP allowlisting: Stripe publishes its webhook IP ranges in a JSON file (linked from Stripe's developer documentation). You can restrict your webhook endpoint to only accept requests from those IPs at the load balancer or CDN layer, before the request reaches your application. This provides defense-in-depth: even if an attacker finds a signature verification bypass, they can't reach your endpoint from unauthorized IPs. Update the allowlist periodically, as Stripe adds new IP ranges when expanding infrastructure.

Rate limiting at the endpoint level: Stripe delivers webhooks in bursts during high-activity periods (large batches of renewals, a fraud spike triggering many failure events). Your webhook endpoint should handle concurrent requests without queuing issues. If your database writes become a bottleneck under burst load, consider queuing webhook events (Upstash QStash, Inngest, or AWS SQS) and processing them with a worker. The queue provides natural rate limiting and makes retries easy if processing fails.

Secrets rotation: if your `STRIPE_WEBHOOK_SECRET` is compromised, rotate it in Stripe Dashboard immediately (Dashboard → Developers → Webhooks → [endpoint] → Signing secret → Roll secret). Stripe supports a secret rotation window that allows both the old and new secrets to be valid simultaneously for a brief period, so you can deploy the new secret without dropping events during the transition. This is the same pattern used for API key rotation — deploy the new secret first, then revoke the old one after confirming delivery is working.

## Methodology

Stripe API version referenced: 2024-12-18.acacia. Code examples use the `stripe` npm package 16.x. Webhook retry behavior (72-hour window, exponential backoff) sourced from Stripe's official webhook documentation. Stripe CLI version: 1.22.x, which introduced Docker-compatible forwarding. All webhook event types and field names are based on Stripe's published event reference as of March 2026. The idempotency pattern using a `StripeEvent` table is one of several valid approaches — alternatives include distributed locks (Redis) or Postgres advisory locks for higher-throughput webhook processing. IP allowlist data sourced from Stripe's published webhook IP range JSON at the URL in their developer documentation. The state machine transition table in "Designing Event-Driven State Machines" reflects behavior as of the Stripe Billing API version cited; subscription lifecycle behavior can differ between API versions, so test thoroughly after any Stripe API version upgrade.

---

*Explore and compare payment APIs at [APIScout](https://apiscout.dev).*

*Related: [Build a Payment System: Stripe + Plaid 2026](/blog/building-payment-system-stripe-plaid-billing-2026), [Building a SaaS Backend](/blog/building-saas-backend-auth-stripe-posthog-resend-2026), [How to Add Stripe Payments to Your Next.js App](/blog/how-to-add-stripe-payments-nextjs-2026)*
