DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Stripe Webhooks: Handle Payment Events Without Losing Data

Why Webhooks Are Hard

Stripe calls your server when things happen: payment succeeded, subscription renewed, card declined. If your handler fails, Stripe retries. If your server is down, events queue up.

The failure modes are nasty:

  • Process the same event twice → duplicate fulfillment
  • Miss an event → user pays but doesn't get access
  • Process out of order → subscription logic breaks

Here's how to handle this correctly.

Webhook Signature Verification

Never process events without verifying they came from Stripe:

import Stripe from 'stripe';
import { buffer } from 'micro'; // or read raw body

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

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.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 new Response('Webhook Error', { status: 400 });
  }

  await handleEvent(event);
  return new Response('OK', { status: 200 });
}
Enter fullscreen mode Exit fullscreen mode

Critical: Use the raw request body, not parsed JSON. Any transformation breaks the signature.

Idempotency: Process Once, No Matter What

Stripe retries failed webhooks. Your handler must be safe to call multiple times with the same event.

async function handleEvent(event: Stripe.Event) {
  // Check if we've already processed this event
  const existing = await db.webhookEvent.findUnique({
    where: { stripeEventId: event.id },
  });

  if (existing?.processedAt) {
    console.log(`Event ${event.id} already processed, skipping`);
    return;
  }

  // Record that we're processing it
  await db.webhookEvent.upsert({
    where: { stripeEventId: event.id },
    create: { stripeEventId: event.id, type: event.type },
    update: {},
  });

  try {
    await processEvent(event);

    // Mark as successfully processed
    await db.webhookEvent.update({
      where: { stripeEventId: event.id },
      data: { processedAt: new Date() },
    });
  } catch (error) {
    // Log failure — Stripe will retry
    await db.webhookEvent.update({
      where: { stripeEventId: event.id },
      data: { error: (error as Error).message },
    });
    throw error; // Return 500 so Stripe retries
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Handler Pattern

async function processEvent(event: Stripe.Event) {
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutCompleted(session);
      break;
    }

    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription);
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      await cancelSubscription(subscription);
      break;
    }

    case 'invoice.payment_failed': {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentFailed(invoice);
      break;
    }

    default:
      console.log(`Unhandled event type: ${event.type}`);
  }
}

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const userId = session.metadata?.userId;
  if (!userId) throw new Error('No userId in session metadata');

  if (session.mode === 'payment') {
    // One-time purchase
    await fulfillOrder(userId, session);
  } else if (session.mode === 'subscription') {
    // New subscription started
    await activateSubscription(userId, session.subscription as string);
  }
}

async function syncSubscription(subscription: Stripe.Subscription) {
  await db.subscription.upsert({
    where: { stripeSubscriptionId: subscription.id },
    create: {
      stripeSubscriptionId: subscription.id,
      stripeCustomerId: subscription.customer as string,
      status: subscription.status,
      priceId: subscription.items.data[0].price.id,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
    update: {
      status: subscription.status,
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

The Webhook Events Table

model WebhookEvent {
  id             String    @id @default(cuid())
  stripeEventId  String    @unique
  type           String
  processedAt    DateTime?
  error          String?
  createdAt      DateTime  @default(now())

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

This table is your audit log and idempotency guard in one.

Local Testing

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

# Log in
stripe login

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

# Trigger test events
stripe trigger payment_intent.succeeded
stripe trigger customer.subscription.created
Enter fullscreen mode Exit fullscreen mode

Deployment Checklist

  • [ ] Webhook secret stored in environment variables
  • [ ] Raw body parsing (not JSON middleware) for webhook endpoint
  • [ ] Idempotency check using event ID
  • [ ] Retry on failure (return 5xx, don't swallow errors)
  • [ ] Webhook events table for audit log
  • [ ] All relevant event types handled
  • [ ] Tested with Stripe CLI locally
  • [ ] Webhook endpoint registered in Stripe Dashboard

Stripe's retry behavior means your handlers run in distributed, unreliable conditions. Design for it from the start.


Complete Stripe webhook handler with idempotency, subscription sync, and one-time fulfillment: Whoff Agents AI SaaS Starter Kit.

Top comments (0)