Claude Code for Stripe Webhooks: Event Processing Patterns — Claude Skills 360 Blog
Blog / Backend / Claude Code for Stripe Webhooks: Event Processing Patterns
Backend

Claude Code for Stripe Webhooks: Event Processing Patterns

Published: March 30, 2027
Read time: 8 min read
By: Claude Skills 360

Stripe webhooks deliver payment events to your server — stripe.webhooks.constructEvent(rawBody, signature, webhookSecret) verifies the signature and parses the event. Always return 200 immediately so Stripe doesn’t retry; process asynchronously if needed. Idempotency is critical: Stripe retries failed webhooks, so deduplicate by event.id in your database before processing. checkout.session.completed fires when a Checkout Session payment succeeds — provision access here. payment_intent.succeeded and payment_intent.payment_failed handle direct charge flows. customer.subscription.created/updated/deleted manage subscription lifecycle. invoice.paid/payment_failed handle recurring billing. charge.refunded processes refunds. The Stripe CLI stripe listen --forward-to localhost:3000/api/webhooks routes live events in development. Claude Code generates type-safe webhook handlers, idempotency guards, event-to-action routers, and subscription lifecycle management patterns.

CLAUDE.md for Stripe Webhooks

## Stripe Webhooks Stack
- Version: stripe >= 16.0
- Verify: stripe.webhooks.constructEvent(rawBody, sig, secret) — raw body required
- Idempotency: check event.id in DB before processing — Stripe retries on non-200
- Return 200 immediately — process async if needed, Stripe times out at 30s
- Checkout: checkout.session.completed → provision access (check payment_status)
- Subscriptions: customer.subscription.updated → sync plan, status, period_end
- CLI: stripe listen --forward-to localhost:3000/api/stripe/webhook
- Logs: store processed events in webhook_events table with event_id UNIQUE constraint

Webhook Route Handler

// app/api/stripe/webhook/route.ts — signature verification + routing
import Stripe from "stripe"
import { headers } from "next/headers"
import { processWebhookEvent } from "@/lib/stripe/event-processor"
import { markEventProcessed, wasEventProcessed } from "@/lib/stripe/event-log"

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

export async function POST(req: Request) {
  const rawBody = await req.text()  // Must be raw bytes for signature verification
  const sig = (await headers()).get("stripe-signature")

  if (!sig) {
    return Response.json({ error: "Missing stripe-signature header" }, { status: 400 })
  }

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error("[Stripe Webhook] Signature verification failed:", err)
    return Response.json({ error: "Invalid signature" }, { status: 400 })
  }

  // Idempotency: skip if already processed
  if (await wasEventProcessed(event.id)) {
    console.log(`[Stripe Webhook] Skipping duplicate event ${event.id}`)
    return Response.json({ received: true, duplicate: true })
  }

  // Process event (async for long operations)
  try {
    await processWebhookEvent(event)
    await markEventProcessed(event.id, event.type)
  } catch (err) {
    console.error(`[Stripe Webhook] Processing failed for ${event.type}:`, err)
    // Return 500 to trigger Stripe retry — only for recoverable errors
    return Response.json({ error: "Processing failed" }, { status: 500 })
  }

  return Response.json({ received: true })
}

Event Processor

// lib/stripe/event-processor.ts — type-safe event routing
import Stripe from "stripe"
import {
  handleCheckoutCompleted,
  handlePaymentSucceeded,
  handlePaymentFailed,
  handleSubscriptionCreated,
  handleSubscriptionUpdated,
  handleSubscriptionDeleted,
  handleInvoicePaid,
  handleInvoicePaymentFailed,
  handleRefund,
} from "./event-handlers"

export async function processWebhookEvent(event: Stripe.Event): Promise<void> {
  console.log(`[Stripe] Processing ${event.type} (${event.id})`)

  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session)
      break

    case "payment_intent.succeeded":
      await handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent)
      break

    case "payment_intent.payment_failed":
      await handlePaymentFailed(event.data.object as Stripe.PaymentIntent)
      break

    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,
        event.data.previous_attributes as Partial<Stripe.Subscription>
      )
      break

    case "customer.subscription.deleted":
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription)
      break

    case "invoice.paid":
      await handleInvoicePaid(event.data.object as Stripe.Invoice)
      break

    case "invoice.payment_failed":
      await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice)
      break

    case "charge.refunded":
      await handleRefund(event.data.object as Stripe.Charge)
      break

    default:
      console.log(`[Stripe] Unhandled event type: ${event.type}`)
  }
}

Event Handlers

// lib/stripe/event-handlers.ts — business logic per event
import Stripe from "stripe"
import { db } from "@/lib/db"

export async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  // Only process successful payments
  if (session.payment_status !== "paid") {
    console.log(`[Stripe] Checkout ${session.id} not paid — skipping`)
    return
  }

  const customerId = session.customer as string
  const metadata = session.metadata ?? {}

  if (session.mode === "subscription") {
    // Subscription checkout — subscription created event will handle provisioning
    console.log(`[Stripe] Subscription checkout completed for customer ${customerId}`)
    return
  }

  if (session.mode === "payment") {
    // One-time payment — provision immediately
    const userId = metadata.userId
    const productId = metadata.productId

    if (!userId || !productId) {
      throw new Error(`Missing metadata on checkout session ${session.id}`)
    }

    await db.purchase.create({
      data: {
        userId,
        productId,
        stripeSessionId: session.id,
        amountCents: session.amount_total ?? 0,
        status: "completed",
      },
    })

    // Send fulfillment email
    await sendFulfillmentEmail(userId, productId)
  }
}

export async function handleSubscriptionUpdated(
  subscription: Stripe.Subscription,
  previousAttributes: Partial<Stripe.Subscription>
) {
  const stripeCustomerId = subscription.customer as string

  // Find our user by Stripe customer ID
  const user = await db.user.findFirst({
    where: { stripeCustomerId },
  })

  if (!user) {
    throw new Error(`No user found for Stripe customer ${stripeCustomerId}`)
  }

  const priceId = subscription.items.data[0]?.price.id

  await db.subscription.upsert({
    where: { stripeSubscriptionId: subscription.id },
    create: {
      userId: user.id,
      stripeSubscriptionId: subscription.id,
      stripePriceId: priceId,
      status: subscription.status,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
    update: {
      status: subscription.status,
      stripePriceId: priceId,
      currentPeriodStart: new Date(subscription.current_period_start * 1000),
      currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      cancelAtPeriodEnd: subscription.cancel_at_period_end,
    },
  })

  // Handle downgrades/upgrades
  if (previousAttributes.items) {
    console.log(`[Stripe] Subscription ${subscription.id} plan changed`)
  }
}

export async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  await db.subscription.update({
    where: { stripeSubscriptionId: subscription.id },
    data: {
      status: "cancelled",
      cancelledAt: new Date(),
    },
  })
}

export async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) {
  const stripeCustomerId = invoice.customer as string
  const user = await db.user.findFirst({ where: { stripeCustomerId } })
  if (!user) return

  // Send payment failure notification
  await sendPaymentFailedEmail(user.email, invoice.hosted_invoice_url ?? undefined)
}

export async function handleRefund(charge: Stripe.Charge) {
  if (!charge.refunded) return

  const amountRefundedCents = charge.amount_refunded

  await db.order.update({
    where: { stripePaymentIntentId: charge.payment_intent as string },
    data: {
      refundedAmountCents: amountRefundedCents,
      status: charge.amount === amountRefundedCents ? "fully_refunded" : "partially_refunded",
    },
  })
}

async function sendFulfillmentEmail(_userId: string, _productId: string) { /* ... */ }
async function sendPaymentFailedEmail(_email: string, _url?: string) { /* ... */ }

Idempotency Log

// lib/stripe/event-log.ts — prevent double processing
import { db } from "@/lib/db"

export async function wasEventProcessed(eventId: string): Promise<boolean> {
  const existing = await db.stripeEvent.findUnique({
    where: { stripeEventId: eventId },
  })
  return existing !== null
}

export async function markEventProcessed(eventId: string, eventType: string): Promise<void> {
  await db.stripeEvent.create({
    data: {
      stripeEventId: eventId,
      eventType,
      processedAt: new Date(),
    },
  })
}

// Prisma schema addition:
// model StripeEvent {
//   id            String   @id @default(cuid())
//   stripeEventId String   @unique
//   eventType     String
//   processedAt   DateTime
//   createdAt     DateTime @default(now())
// }

For the Paddle webhooks alternative when marketplace or software license billing with built-in tax compliance (VAT, sales tax) across countries is needed — Paddle handles tax collection as the merchant of record, removing VAT registration obligations for individual vendors, see the payment processor comparison for global SaaS billing. For the Lemon Squeezy alternative when a simpler Stripe alternative with digital product sales, license keys, and affiliate program management is needed specifically for indie developers selling software or courses — Lemon Squeezy is also a merchant of record, see the digital commerce platform guide. The Claude Skills 360 bundle includes Stripe webhook skill sets covering signature verification, idempotency, and subscription lifecycle. Start with the free tier to try payment webhook generation.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free