DEV Community

myougaTheAxo
myougaTheAxo

Posted on

Stripe Payments with Claude Code: Subscriptions, Checkout Sessions, and Webhook Idempotency

"Just add checkout" sounds simple. But real Stripe integration means handling subscription lifecycle events, verifying webhook signatures, preventing duplicate processing, and retrying payment failures correctly.

Claude Code generates the complete payment design from CLAUDE.md rules — not just the happy path, but the failure modes too.


The CLAUDE.md Rules

## Stripe Integration Rules

- Validate price/product server-side — never trust client-sent amounts or priceIds
- Webhook signature verification is mandatory (use rawBody, not parsed body)
- Secret Key is server-only; Publishable Key is client-safe
- Store stripeCustomerId and subscriptionId in DB after successful checkout
- Webhooks handle: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted
- Use Checkout Session — Stripe handles the card form (no PCI scope)
- Price IDs come from env vars only (STRIPE_PRICE_MONTHLY, STRIPE_PRICE_ANNUAL)
- Webhook idempotency: store processed event IDs in webhookEvent table to prevent double processing
Enter fullscreen mode Exit fullscreen mode

With these rules in CLAUDE.md, Claude Code knows the full contract before you ask for a single function.


getOrCreateStripeCustomer()

import Stripe from 'stripe';
import { prisma } from '../lib/prisma';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

export async function getOrCreateStripeCustomer(userId: string): Promise<string> {
  const user = await prisma.user.findUniqueOrThrow({
    where: { id: userId },
    select: { id: true, email: true, name: true, stripeCustomerId: true },
  });

  if (user.stripeCustomerId) {
    return user.stripeCustomerId;
  }

  const customer = await stripe.customers.create({
    email: user.email,
    name: user.name ?? undefined,
    metadata: { userId: user.id },
  });

  await prisma.user.update({
    where: { id: userId },
    data: { stripeCustomerId: customer.id },
  });

  return customer.id;
}
Enter fullscreen mode Exit fullscreen mode

getOrCreateStripeCustomer is idempotent — calling it twice for the same user always returns the same Stripe customer ID. The metadata link (userId) lets you recover the user from any Stripe event.


createCheckoutSession()

export async function createCheckoutSession(params: {
  userId: string;
  priceId: string;
  successUrl: string;
  cancelUrl: string;
}): Promise<string> {
  const customerId = await getOrCreateStripeCustomer(params.userId);

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    line_items: [{ price: params.priceId, quantity: 1 }],
    mode: 'subscription',
    success_url: params.successUrl,
    cancel_url: params.cancelUrl,
    subscription_data: {
      metadata: { userId: params.userId },
    },
  });

  return session.url!;
}
Enter fullscreen mode Exit fullscreen mode

Stripe hosts the entire card form — no PCI scope for your server. The subscription_data.metadata.userId is critical: it links the Stripe subscription back to your user when webhook events arrive.


POST /api/checkout Route

import { z } from 'zod';
import { createCheckoutSession } from '../services/stripe';

const VALID_PLANS: Record<string, string> = {
  monthly: process.env.STRIPE_PRICE_MONTHLY!,
  annual: process.env.STRIPE_PRICE_ANNUAL!,
};

const CheckoutSchema = z.object({
  planId: z.enum(['monthly', 'annual']),
});

export async function POST(req: Request) {
  const session = await getServerSession();
  if (!session?.user?.id) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const body = CheckoutSchema.parse(await req.json());
  const priceId = VALID_PLANS[body.planId];

  // priceId is always from env vars — client cannot inject arbitrary prices
  const url = await createCheckoutSession({
    userId: session.user.id,
    priceId,
    successUrl: `${process.env.NEXT_PUBLIC_URL}/dashboard?success=1`,
    cancelUrl: `${process.env.NEXT_PUBLIC_URL}/pricing`,
  });

  return Response.json({ url });
}
Enter fullscreen mode Exit fullscreen mode

VALID_PLANS maps plan names to price IDs from env vars. The client sends "monthly" or "annual" — never a raw price ID. Server-side validation prevents price manipulation.


Webhook: Signature Verification + Idempotency

import { headers } from 'next/headers';

export async function POST(req: Request) {
  const rawBody = await req.text(); // rawBody required for signature verification
  const sig = headers().get('stripe-signature')!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    return Response.json({ error: 'Invalid signature' }, { status: 400 });
  }

  // Idempotency check — prevent double processing
  const existing = await prisma.webhookEvent.findUnique({
    where: { stripeEventId: event.id },
  });
  if (existing) {
    return Response.json({ received: true }); // already processed
  }

  await prisma.webhookEvent.create({
    data: { stripeEventId: event.id, type: event.type },
  });

  // Respond immediately — Stripe retries if you timeout
  // Process async
  handleWebhookEvent(event).catch(console.error);

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

async function handleWebhookEvent(event: Stripe.Event): Promise<void> {
  switch (event.type) {
    case 'checkout.session.completed': {
      const session = event.data.object as Stripe.Checkout.Session;
      const userId = session.subscription_data?.metadata?.userId
        ?? session.metadata?.userId;
      if (!userId) return;

      await prisma.user.update({
        where: { id: userId },
        data: {
          subscriptionId: session.subscription as string,
          subscriptionStatus: 'active',
          plan: session.metadata?.planId ?? 'monthly',
        },
      });
      break;
    }

    case 'customer.subscription.deleted': {
      const subscription = event.data.object as Stripe.Subscription;
      const userId = subscription.metadata?.userId;
      if (!userId) return;

      await prisma.user.updateMany({
        where: { subscriptionId: subscription.id },
        data: { subscriptionStatus: 'cancelled' },
      });
      break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Two key patterns here:

  1. rawBody for signature verification — parsed JSON breaks the HMAC check. req.text() keeps the raw bytes intact.
  2. Idempotency via webhookEvent table — Stripe retries on timeout or error. Without deduplication, checkout.session.completed can fire multiple times and charge the user once but credit them twice.

What CLAUDE.md Gives You

  • VALID_PLANS from env vars → server-side price validation, no client injection
  • getOrCreateStripeCustomer → idempotent customer creation with DB link
  • Checkout Session → Stripe hosts the card form, zero PCI scope
  • rawBody + stripe.webhooks.constructEvent → tamper-proof webhook verification
  • webhookEvent table → idempotency, safe retry handling

Without these rules, Stripe integration defaults to "happy path only" — subscription cancellations go unhandled, duplicate webhook events corrupt subscription state, and client-sent price IDs open a price manipulation vector.


Want the complete security-focused CLAUDE.md ruleset — including payment validation, webhook hardening, input sanitization, and OWASP Top 10 coverage? It's packaged as a Security Pack on PromptWorks (¥1,480, /security-check).


What broke first in your Stripe integration — webhooks, subscription lifecycle, or something else entirely?

Top comments (0)