Skip to main content

Stripe Webhooks 2026: Setup and Best Practices

·APIScout Team
stripewebhookspaymentsnext-jsidempotency2026

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

// 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.

// 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);
  }
}
// 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

// 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

# 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.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

// 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

// 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
  }
}
# 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

Explore and compare payment APIs at APIScout.

Comments