DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Building a Stripe webhook handler that actually handles every edge case

Stripe will deliver the same webhook twice. Your server will crash during webhook processing. A payment will succeed but your database won't know about it. All three of these will happen in production.

Here's how to handle every edge case before it costs you money or customers.

Why webhooks are harder than they look

The mental model most developers have: Stripe calls your endpoint, you update the database, done.

The reality:

  • Stripe retries failed webhooks for 72 hours
  • Your server can crash mid-processing (after Stripe gets a 200, before you write to the database)
  • The same event can arrive out of order (subscription updated before subscription created)
  • Your endpoint can be down during a critical payment event
  • Stripe test mode and live mode send to the same URL if you're not careful

None of this is rare. All of it will happen if you run a production SaaS.

The complete webhook handler

// app/api/stripe/webhook/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { prisma } from '@/lib/db';
import { handleSubscriptionChange, handlePaymentSuccess } from '@/lib/billing';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: NextRequest) {
  const body = await request.text(); // Must be raw text for signature verification
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  // Step 1: Verify the webhook signature
  try {
    event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Step 2: Idempotency check — have we already processed this event?
  const existingEvent = await prisma.stripeEvent.findUnique({
    where: { stripeEventId: event.id },
  });

  if (existingEvent) {
    // Already processed — return 200 so Stripe stops retrying
    console.log(`Duplicate event ignored: ${event.id}`);
    return NextResponse.json({ received: true, duplicate: true });
  }

  // Step 3: Record the event BEFORE processing (prevents double-processing on crash)
  await prisma.stripeEvent.create({
    data: {
      stripeEventId: event.id,
      type: event.type,
      status: 'processing',
      rawPayload: JSON.stringify(event),
    },
  });

  try {
    // Step 4: Route to appropriate handler
    await handleEvent(event);

    // Step 5: Mark as successfully processed
    await prisma.stripeEvent.update({
      where: { stripeEventId: event.id },
      data: { status: 'processed', processedAt: new Date() },
    });

    return NextResponse.json({ received: true });
  } catch (error) {
    // Step 6: Mark as failed — Stripe will retry
    await prisma.stripeEvent.update({
      where: { stripeEventId: event.id },
      data: {
        status: 'failed',
        error: error instanceof Error ? error.message : String(error),
      },
    });

    console.error(`Webhook processing failed for ${event.id}:`, error);

    // Return 500 so Stripe retries
    return NextResponse.json({ error: 'Processing failed' }, { status: 500 });
  }
}

async function handleEvent(event: Stripe.Event) {
  switch (event.type) {
    case 'customer.subscription.created':
      await handleSubscriptionCreated(event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.updated':
      await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCanceled(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;
    case 'customer.subscription.trial_will_end':
      await handleTrialEnding(event.data.object as Stripe.Subscription);
      break;
    case 'checkout.session.completed':
      await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
      break;
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The idempotency table

The stripeEvent table is the most important part. Here's the Prisma schema:

model StripeEvent {
  id            String    @id @default(cuid())
  stripeEventId String    @unique  // The critical unique constraint
  type          String
  status        String    // 'processing' | 'processed' | 'failed'
  rawPayload    String    // Keep the full event for debugging
  error         String?
  processedAt   DateTime?
  createdAt     DateTime  @default(now())

  @@index([type])
  @@index([status])
  @@index([createdAt])
}
Enter fullscreen mode Exit fullscreen mode

The stripeEventId @unique constraint is the guard. If two requests for the same event hit your server simultaneously (Stripe retrying a slow response), only one will succeed the create call — the other will get a unique constraint violation and you handle it gracefully.

The subscription handlers

async function handleSubscriptionCreated(subscription: Stripe.Subscription) {
  const customerId = subscription.customer as string;

  const user = await prisma.user.findFirst({
    where: { stripeCustomerId: customerId },
  });

  if (!user) {
    // Customer exists in Stripe but not in your DB — 
    // could be a test event or a race condition with signup
    console.error(`No user found for Stripe customer: ${customerId}`);
    throw new Error(`User not found: ${customerId}`); // Trigger retry
  }

  await prisma.subscription.upsert({
    where: { userId: user.id },
    create: {
      userId: user.id,
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      status: subscription.status,
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
    update: {
      stripeSubscriptionId: subscription.id,
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      status: subscription.status,
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  });

  // Send welcome email
  await sendWelcomeEmail(user.email, subscription);
}

async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  // This fires on EVERY subscription change — plan changes, renewals, cancellation scheduling
  // Use upsert because the subscription might not be in your DB yet (race condition)

  await prisma.subscription.upsert({
    where: { stripeSubscriptionId: subscription.id },
    create: {
      // ... (same as created handler)
    },
    update: {
      stripePriceId: subscription.items.data[0].price.id,
      stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
      status: subscription.status,
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  });

  // If plan changed, notify user
  if (subscription.items.data[0].price.id !== previousPriceId) {
    await sendPlanChangeEmail(userId, newPlan);
  }
}

async function handleSubscriptionCanceled(subscription: Stripe.Subscription) {
  await prisma.subscription.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status: 'canceled',
      canceledAt: new Date(),
    },
  });

  // Downgrade user permissions immediately
  // Note: Don't delete data — they might resubscribe
  await sendCancellationEmail(userId);
  await sendChurnSurveyEmail(userId); // Find out why
}
Enter fullscreen mode Exit fullscreen mode

Payment failure handling — the one that costs you money

This is the event most developers don't handle properly:

async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;

  const subscription = await prisma.subscription.findFirst({
    where: { stripeSubscriptionId: subscriptionId },
    include: { user: true },
  });

  if (!subscription) return;

  const attemptCount = invoice.attempt_count;

  // Stripe retries: day 1, day 3, day 5, day 7 (configurable in Dashboard)
  // Tailor your response to the retry attempt

  if (attemptCount === 1) {
    // First failure — gentle email, check card details
    await sendPaymentFailedEmail(subscription.user.email, {
      message: "We couldn't process your payment. Please update your card.",
      urgency: 'low',
    });
  } else if (attemptCount === 2 || attemptCount === 3) {
    // Second/third failure — more urgent, account at risk
    await sendPaymentFailedEmail(subscription.user.email, {
      message: "Your account will be suspended if payment isn't resolved.",
      urgency: 'high',
    });
  } else {
    // Final failure — Stripe will cancel the subscription
    // The canceled event will fire separately
    await sendFinalPaymentFailureEmail(subscription.user.email);
  }

  // Track dunning state in your database
  await prisma.subscription.update({
    where: { id: subscription.id },
    data: {
      paymentFailedAt: new Date(),
      paymentAttempts: attemptCount,
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

Verifying your webhook endpoint works

Local development with Stripe CLI:

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

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

# In another terminal, trigger a test event
stripe trigger customer.subscription.created
stripe trigger invoice.payment_failed
stripe trigger checkout.session.completed
Enter fullscreen mode Exit fullscreen mode

The CLI prints your webhook signing secret on startup — put this in .env.local as STRIPE_WEBHOOK_SECRET.

Don't use the same webhook secret for test and production. Stripe gives you separate secrets. Mixing them is how test events end up processing against real customer data.

The events you must handle

Minimum set for a subscription SaaS:

Event Why
customer.subscription.created User just subscribed
customer.subscription.updated Plan change, renewal, anything
customer.subscription.deleted Subscription ended (cancelled or payment failed)
invoice.payment_succeeded Payment processed — extend access period
invoice.payment_failed Payment failed — start dunning flow
customer.subscription.trial_will_end 3 days before trial ends — prompt to add card
checkout.session.completed One-time purchase completed

Events you should handle but often skip:

Event Why
customer.updated Email or billing address changed
invoice.upcoming Upcoming renewal — send reminder
charge.dispute.created Chargeback filed — respond within 7 days
payment_method.detached Card removed — ask for replacement

The mistake that costs real money

Here's the failure mode I see constantly:

// BROKEN — returns 200 before actually processing
export async function POST(request: NextRequest) {
  const event = await verifyWebhook(request);

  // Acknowledge immediately (correct intent, wrong placement)
  const response = NextResponse.json({ received: true }); // 200 to Stripe

  // Process after acknowledging
  await updateDatabase(event); // If this fails, Stripe thinks it succeeded

  return response;
}
Enter fullscreen mode Exit fullscreen mode

If updateDatabase throws after you've already sent the 200, Stripe won't retry. Your customer paid. Your database doesn't know. You've lost the event.

The fix is simple: process first, respond second. Or process within a transaction that can be rolled back.


What this looks like assembled

This pattern — idempotency table, raw event storage, proper status tracking, retry-safe handlers — is exactly what the AI SaaS Starter Kit ships with. The Stripe integration is complete: subscription lifecycle, customer portal, payment failure handling, dunning emails, and the webhook handler above.

You clone it and the webhook infrastructure is already there. The 10 hours you'd spend building this correctly are already spent.


Stripe's official webhook docs are comprehensive — docs.stripe.com/webhooks. For the complete list of event types, check docs.stripe.com/api/events/types.

Top comments (0)