Tutorials
|stacknotice.com
18 min left|
0%
|3,600 words
Tutorials

Stripe Webhooks and Subscriptions: The Production-Grade Guide (2026)

How to handle Stripe webhooks idempotently, sync subscription state to your DB, and avoid the bugs that break billing at 3 AM. SaaS Series #4.

May 26, 202618 min read
Share:
Stripe Webhooks and Subscriptions: The Production-Grade Guide (2026)

Stripe's documentation shows you how to verify a webhook. It doesn't show you what happens when the same webhook fires twice, when your DB write fails mid-handler, or when a user upgrades and your app still shows the free plan. This guide covers all of that.

This is SaaS Series #4. The series covers real production decisions for building a SaaS:


The core problem with Stripe webhooks

Stripe delivers webhooks at least once. That means the same event can arrive multiple times — especially during retries. If your handler isn't idempotent, you'll double-charge, double-provision, or corrupt your subscription state.

The second problem: event ordering is not guaranteed. A customer.subscription.updated can arrive before customer.subscription.created. Your handler needs to handle that.

The third problem: your DB write can fail after Stripe considers the webhook delivered. Stripe marks a webhook as delivered when your endpoint returns 2xx. If your DB write happens after that and throws, Stripe won't retry — and your data is out of sync.

The solution is a pattern that combines:

  1. Signature verification (is this really from Stripe?)
  2. Idempotency keys (have I already processed this event?)
  3. Atomic DB writes (don't return 2xx until data is saved)

Schema: what to store

Before touching the webhook handler, design your schema to reflect Stripe's data model.

// db/schema.ts
import { pgTable, text, timestamp, integer, boolean } from 'drizzle-orm/pg-core'
 
export const customers = pgTable('customers', {
  id: text('id').primaryKey(), // your internal user ID
  stripeCustomerId: text('stripe_customer_id').unique(),
  email: text('email').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
})
 
export const subscriptions = pgTable('subscriptions', {
  id: text('id').primaryKey(), // stripe subscription ID
  customerId: text('customer_id')
    .notNull()
    .references(() => customers.id, { onDelete: 'cascade' }),
  stripeCustomerId: text('stripe_customer_id').notNull(),
  status: text('status').notNull(), // active | trialing | past_due | canceled | incomplete
  priceId: text('price_id').notNull(),
  productId: text('product_id').notNull(),
  currentPeriodStart: timestamp('current_period_start').notNull(),
  currentPeriodEnd: timestamp('current_period_end').notNull(),
  cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false),
  canceledAt: timestamp('canceled_at'),
  trialStart: timestamp('trial_start'),
  trialEnd: timestamp('trial_end'),
  updatedAt: timestamp('updated_at').defaultNow().$onUpdate(() => new Date()),
})
 
// Idempotency: track processed webhook events
export const stripeEvents = pgTable('stripe_events', {
  id: text('id').primaryKey(), // stripe event ID (evt_...)
  type: text('type').notNull(),
  processedAt: timestamp('processed_at').defaultNow(),
})

The stripeEvents table is your idempotency log. Before processing any event, check if it's already there. If yes, return 200 immediately.


The webhook handler

// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { db } from '@/lib/db'
import { stripeEvents, subscriptions, customers } from '@/db/schema'
import { eq } from 'drizzle-orm'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
})
 
export async function POST(req: Request) {
  const body = await req.text()
  const signature = (await headers()).get('stripe-signature')!
 
  let event: Stripe.Event
 
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook signature verification failed:', err)
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }
 
  // Idempotency check — return 200 if already processed
  try {
    await db.insert(stripeEvents).values({
      id: event.id,
      type: event.type,
    })
  } catch {
    // Unique constraint violation = already processed
    return NextResponse.json({ received: true, duplicate: true })
  }
 
  try {
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
        break
 
      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await handleSubscriptionUpsert(event.data.object as Stripe.Subscription)
        break
 
      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
        break
 
      case 'invoice.payment_succeeded':
        await handlePaymentSucceeded(event.data.object as Stripe.Invoice)
        break
 
      case 'invoice.payment_failed':
        await handlePaymentFailed(event.data.object as Stripe.Invoice)
        break
 
      default:
        // Unknown event type — log and ignore, don't fail
        console.log(`Unhandled event type: ${event.type}`)
    }
  } catch (err) {
    console.error(`Error processing event ${event.id}:`, err)
    // Return 500 so Stripe retries — but only if the error isn't our idempotency key
    // The idempotency key is already inserted, so on retry it'll be caught above
    // This means: fix the bug, then manually replay the event from Stripe dashboard
    return NextResponse.json(
      { error: 'Webhook processing failed' },
      { status: 500 }
    )
  }
 
  return NextResponse.json({ received: true })
}
Never return 2xx before your DB write

If you return 200 and then the DB throws, Stripe won't retry. Return 500 on DB errors so Stripe retries the event. Your idempotency key is already stored, so retries are safe.


Handling checkout completion

When a user completes checkout, Stripe fires checkout.session.completed. This is where you link the Stripe customer to your user.

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId
  if (!userId) {
    throw new Error(`No userId in checkout session metadata: ${session.id}`)
  }
 
  // Link Stripe customer to your user
  await db
    .update(customers)
    .set({ stripeCustomerId: session.customer as string })
    .where(eq(customers.id, userId))
 
  // The subscription is created in a separate event,
  // but we can also fetch it here for immediate sync
  if (session.subscription) {
    const subscription = await stripe.subscriptions.retrieve(
      session.subscription as string
    )
    await upsertSubscription(userId, subscription)
  }
}

Critical: pass userId as metadata when creating the checkout session:

// lib/stripe.ts
export async function createCheckoutSession(userId: string, priceId: string) {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    metadata: { userId }, // ← this is how you connect Stripe to your user
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    // Pre-fill email if you have it
    customer_email: await getUserEmail(userId),
  })
 
  return session.url!
}

Upserting subscription state

Both created and updated events share the same handler — they both upsert the subscription:

async function handleSubscriptionUpsert(sub: Stripe.Subscription) {
  const stripeCustomerId = sub.customer as string
 
  // Find the user by Stripe customer ID
  const customer = await db.query.customers.findFirst({
    where: eq(customers.stripeCustomerId, stripeCustomerId),
  })
 
  if (!customer) {
    // Can happen if checkout.session.completed hasn't been processed yet
    // (event ordering is not guaranteed)
    throw new Error(`No customer found for Stripe customer: ${stripeCustomerId}`)
  }
 
  await upsertSubscription(customer.id, sub)
}
 
async function upsertSubscription(userId: string, sub: Stripe.Subscription) {
  const price = sub.items.data[0]?.price
  if (!price) throw new Error(`No price in subscription ${sub.id}`)
 
  await db
    .insert(subscriptions)
    .values({
      id: sub.id,
      customerId: userId,
      stripeCustomerId: sub.customer as string,
      status: sub.status,
      priceId: price.id,
      productId: price.product as string,
      currentPeriodStart: new Date(sub.current_period_start * 1000),
      currentPeriodEnd: new Date(sub.current_period_end * 1000),
      cancelAtPeriodEnd: sub.cancel_at_period_end,
      canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
      trialStart: sub.trial_start ? new Date(sub.trial_start * 1000) : null,
      trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
    })
    .onConflictDoUpdate({
      target: subscriptions.id,
      set: {
        status: sub.status,
        priceId: price.id,
        productId: price.product as string,
        currentPeriodStart: new Date(sub.current_period_start * 1000),
        currentPeriodEnd: new Date(sub.current_period_end * 1000),
        cancelAtPeriodEnd: sub.cancel_at_period_end,
        canceledAt: sub.canceled_at ? new Date(sub.canceled_at * 1000) : null,
        trialStart: sub.trial_start ? new Date(sub.trial_start * 1000) : null,
        trialEnd: sub.trial_end ? new Date(sub.trial_end * 1000) : null,
        updatedAt: new Date(),
      },
    })
}

Handling cancellations and payment failures

async function handleSubscriptionDeleted(sub: Stripe.Subscription) {
  // Stripe sets status to 'canceled' before sending this event
  // The upsert handles this — status will be 'canceled'
  await handleSubscriptionUpsert(sub)
}
 
async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  if (!invoice.subscription) return
 
  // Refresh the subscription from Stripe to get updated period dates
  const sub = await stripe.subscriptions.retrieve(invoice.subscription as string)
  await handleSubscriptionUpsert(sub)
}
 
async function handlePaymentFailed(invoice: Stripe.Invoice) {
  if (!invoice.subscription) return
 
  // Stripe will retry the payment and eventually set status to 'past_due'
  // The subscription.updated event handles this automatically
  // Here you can send a dunning email
  const customer = await db.query.customers.findFirst({
    where: eq(customers.stripeCustomerId, invoice.customer as string),
  })
 
  if (customer) {
    await sendPaymentFailedEmail(customer.email, invoice.amount_due)
  }
}

Reading subscription in your app

Never hit the Stripe API on every request to check if a user is subscribed. Read from your own DB.

// lib/subscription.ts
import { cache } from 'react'
import { auth } from '@clerk/nextjs/server'
import { db } from '@/lib/db'
import { subscriptions, customers } from '@/db/schema'
import { eq, and } from 'drizzle-orm'
 
export const getSubscription = cache(async () => {
  const { userId } = await auth()
  if (!userId) return null
 
  const result = await db
    .select({ subscription: subscriptions })
    .from(subscriptions)
    .innerJoin(customers, eq(subscriptions.customerId, customers.id))
    .where(
      and(
        eq(customers.id, userId),
        // Only return active subscriptions
        // 'trialing' is also valid — user hasn't paid yet but has access
      )
    )
    .orderBy(subscriptions.updatedAt)
    .limit(1)
 
  return result[0]?.subscription ?? null
})
 
export async function isPro(): Promise<boolean> {
  const sub = await getSubscription()
  return sub?.status === 'active' || sub?.status === 'trialing'
}
 
export async function isSubscriptionActive(): Promise<boolean> {
  const sub = await getSubscription()
  if (!sub) return false
  return ['active', 'trialing'].includes(sub.status)
}

Use it in Server Components:

// app/dashboard/page.tsx
import { isPro } from '@/lib/subscription'
import { redirect } from 'next/navigation'
 
export default async function DashboardPage() {
  const pro = await isPro()
 
  if (!pro) {
    redirect('/pricing')
  }
 
  return <Dashboard />
}
Cache subscription reads per request

The cache() wrapper from React deduplicates calls within the same request. Call getSubscription() in multiple components — it only hits the DB once.


Customer portal: self-service management

Let users manage their subscription (upgrade, downgrade, cancel) without you building any UI. Stripe's Customer Portal handles it all.

// app/api/billing/portal/route.ts
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { db } from '@/lib/db'
import { customers } from '@/db/schema'
import { eq } from 'drizzle-orm'
 
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
 
export async function POST() {
  const { userId } = await auth()
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }
 
  const customer = await db.query.customers.findFirst({
    where: eq(customers.id, userId),
  })
 
  if (!customer?.stripeCustomerId) {
    return NextResponse.json({ error: 'No billing account' }, { status: 404 })
  }
 
  const portalSession = await stripe.billingPortal.sessions.create({
    customer: customer.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  })
 
  return NextResponse.json({ url: portalSession.url })
}
// components/ManageBillingButton.tsx
'use client'
 
export function ManageBillingButton() {
  async function handleClick() {
    const res = await fetch('/api/billing/portal', { method: 'POST' })
    const { url } = await res.json()
    window.location.href = url
  }
 
  return (
    <button onClick={handleClick}>
      Manage Billing
    </button>
  )
}

Configure the portal in your Stripe Dashboard — enable the features you want users to control (cancel, upgrade, update payment method).


Testing with Stripe CLI

Never test webhooks manually. Use the Stripe CLI to forward events to your local dev environment:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe
 
# Login
stripe login
 
# Forward webhooks to local dev
stripe listen --forward-to localhost:3000/api/webhooks/stripe

This gives you a local webhook signing secret. Add it to your .env.local:

STRIPE_WEBHOOK_SECRET=whsec_... # from the CLI output

Trigger specific events to test your handlers:

# Simulate a successful subscription
stripe trigger checkout.session.completed
 
# Simulate payment failure
stripe trigger invoice.payment_failed
 
# Simulate cancellation
stripe trigger customer.subscription.deleted

For end-to-end testing, use Stripe's test card numbers:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • Requires auth: 4000 0025 0000 3155
  • Always fails: 4000 0000 0000 9995

Handling the race condition between checkout and subscription events

The most common production bug: checkout.session.completed fires and you try to link the customer, but customer.subscription.created fires before it (or milliseconds after, creating a DB consistency issue).

The safest approach: expand the subscription in the checkout session itself:

export async function createCheckoutSession(userId: string, priceId: string) {
  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: priceId, quantity: 1 }],
    metadata: { userId },
    // Expand subscription data so we have it immediately
    expand: ['subscription'],
    subscription_data: {
      metadata: { userId }, // also set on subscription for direct lookups
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
  })
 
  return session
}

With metadata: { userId } on the subscription itself, your handleSubscriptionUpsert can look up the user directly from the subscription's metadata, bypassing the customer table join entirely:

async function handleSubscriptionUpsert(sub: Stripe.Subscription) {
  // Try metadata first (faster, no join needed)
  const userId = sub.metadata?.userId
 
  if (userId) {
    await upsertSubscription(userId, sub)
    return
  }
 
  // Fallback: look up by Stripe customer ID
  const customer = await db.query.customers.findFirst({
    where: eq(customers.stripeCustomerId, sub.customer as string),
  })
 
  if (!customer) {
    throw new Error(`No customer for Stripe customer: ${sub.customer}`)
  }
 
  await upsertSubscription(customer.id, sub)
}

Upgrade and downgrade handling

When a user changes plan, Stripe fires customer.subscription.updated with the new price ID. Your upsertSubscription already handles this — it overwrites priceId and productId with the latest values.

The UI side: read the plan from your DB, compare productId against your plan constants:

// lib/plans.ts
export const PLANS = {
  free: {
    name: 'Free',
    productId: null, // no subscription
  },
  pro: {
    name: 'Pro',
    productId: process.env.STRIPE_PRO_PRODUCT_ID!,
    priceId: {
      monthly: process.env.STRIPE_PRO_PRICE_MONTHLY!,
      yearly: process.env.STRIPE_PRO_PRICE_YEARLY!,
    },
  },
  enterprise: {
    name: 'Enterprise',
    productId: process.env.STRIPE_ENTERPRISE_PRODUCT_ID!,
    priceId: {
      monthly: process.env.STRIPE_ENTERPRISE_PRICE_MONTHLY!,
      yearly: process.env.STRIPE_ENTERPRISE_PRICE_YEARLY!,
    },
  },
} as const
 
export type PlanName = keyof typeof PLANS
 
export async function getCurrentPlan(): Promise<PlanName> {
  const sub = await getSubscription()
  if (!sub || !['active', 'trialing'].includes(sub.status)) return 'free'
 
  if (sub.productId === PLANS.enterprise.productId) return 'enterprise'
  if (sub.productId === PLANS.pro.productId) return 'pro'
  return 'free'
}

Production checklist

1
Verify your webhook secret is set

STRIPE_WEBHOOK_SECRET from the Stripe Dashboard → Webhooks → your endpoint → Signing secret. Different from the CLI secret used in local dev.

2
Register all events in Stripe Dashboard

Dashboard → Developers → Webhooks → Add endpoint. Select: checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_succeeded, invoice.payment_failed.

3
Test the idempotency

In Stripe Dashboard → Webhooks → your endpoint → find any event → click "Resend". Your handler should return 200 without double-processing.

4
Test payment failure dunning

Use test card 4000 0000 0000 9995 to trigger a payment failure. Verify your user gets an email and subscription status shows past_due.

5
Test cancellation at period end

Cancel a subscription in the Customer Portal. Verify cancelAtPeriodEnd is true and the user retains access until currentPeriodEnd.

Never trust the client for subscription status

Don't pass subscription status from the client side. Always read from your DB in Server Components or Route Handlers. A motivated user can spoof client-side state.


What's next in the series

For the Stripe API setup, also check the Vercel AI SDK guide — it shows how to gate AI features behind subscription checks.

#stripe#nextjs#saas#webhooks#subscriptions
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.