← All guides

Stripe Integration with Claude Code (2026)

How to integrate Stripe with Claude Code — checkout sessions, webhook verification, subscription management, customer portal, idempotency keys.

🇰🇷 한국어로 보기 →

Stripe Integration with Claude Code (2026)

To integrate Stripe with Claude Code, tell it your stack (Next.js App Router, TypeScript), paste your Stripe secret key, and prompt it to scaffold checkout session creation, a webhook handler with raw body parsing, and subscription sync to your database. Claude Code generates all three in one pass — the API route, the webhook handler with stripe.webhooks.constructEvent signature verification, and the DB upsert logic. You'll have a working payment flow in under 30 minutes. Start with setup and secrets, then layer in subscriptions and the customer portal.


Setup: CLI Installation and Secrets

npm install stripe @stripe/stripe-js
brew install stripe/stripe-cli/stripe && stripe login

Add secrets to .env.local:

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...   # from: stripe listen --print-secret
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

Prompt Claude Code with your stack details and all three files — checkout route, webhook handler, DB sync — are scaffolded in one pass. See the Claude Code Complete Guide for the general scaffolding workflow.


Checkout Session Creation

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
});

export async function POST(req: NextRequest) {
  const { priceId, customerId, userId } = await req.json();
  const idempotencyKey = `checkout-${userId}-${priceId}`;

  const session = await stripe.checkout.sessions.create(
    {
      mode: "subscription",
      payment_method_types: ["card"],
      line_items: [{ price: priceId, quantity: 1 }],
      customer: customerId ?? undefined,
      success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
      metadata: { userId },
    },
    { idempotencyKey }
  );

  return NextResponse.json({ url: session.url });
}

300 production-tested prompts for Stripe, payments, and Claude Code workflows.

Get P1 Power Prompts 300 ($29) →


Webhook Handler with Signature Verification

Stripe webhooks require raw body parsing — Next.js's default JSON parsing breaks the HMAC check. Always use req.arrayBuffer().

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { syncSubscriptionToDb } from "@/lib/db/subscriptions";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2024-12-18.acacia",
});

export async function POST(req: NextRequest) {
  const rawBody = Buffer.from(await req.arrayBuffer()); // do NOT use req.json()
  const sig = req.headers.get("stripe-signature")!;

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

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.CheckoutSession;
      if (session.subscription) {
        await syncSubscriptionToDb(session.subscription as string, {
          userId: session.metadata?.userId,
          status: "active",
        });
      }
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      await syncSubscriptionToDb(sub.id, {
        status: sub.status,
        cancelAtPeriodEnd: sub.cancel_at_period_end,
        currentPeriodEnd: new Date(sub.current_period_end * 1000),
      });
      break;
    }
  }

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

See Claude Code Database Migrations for Prisma migration patterns to store subscription state.


Subscription Status Sync to DB

// lib/db/subscriptions.ts
export async function syncSubscriptionToDb(
  stripeSubscriptionId: string,
  data: { userId?: string | null; status?: string; cancelAtPeriodEnd?: boolean; currentPeriodEnd?: Date }
) {
  const sub = await stripe.subscriptions.retrieve(stripeSubscriptionId);
  await prisma.subscription.upsert({
    where: { stripeSubscriptionId },
    update: {
      status: data.status ?? sub.status,
      cancelAtPeriodEnd: data.cancelAtPeriodEnd ?? sub.cancel_at_period_end,
      currentPeriodEnd: data.currentPeriodEnd ?? new Date(sub.current_period_end * 1000),
      updatedAt: new Date(),
    },
    create: {
      stripeSubscriptionId,
      userId: data.userId ?? "",
      stripeCustomerId: sub.customer as string,
      status: sub.status,
      priceId: sub.items.data[0]?.price.id ?? "",
      cancelAtPeriodEnd: sub.cancel_at_period_end,
      currentPeriodEnd: new Date(sub.current_period_end * 1000),
    },
  });
}

Subscription Management: Update and Cancel

// Upgrade/downgrade price — charge proration immediately
export async function updateSubscriptionPrice(subscriptionId: string, newPriceId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId);
  return stripe.subscriptions.update(
    subscriptionId,
    { items: [{ id: sub.items.data[0].id, price: newPriceId }], proration_behavior: "always_invoice" },
    { idempotencyKey: `update-sub-${subscriptionId}-${newPriceId}` }
  );
}

// Cancel gracefully at period end, or immediately
export async function cancelSubscription(subscriptionId: string, immediately = false) {
  if (immediately) return stripe.subscriptions.cancel(subscriptionId);
  return stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true });
}

Customer Portal

The Stripe Customer Portal lets users manage billing without any custom UI. Create a portal session server-side and redirect:

// app/api/customer-portal/route.ts
const portalSession = await stripe.billingPortal.sessions.create({
  customer: user.subscription.stripeCustomerId,
  return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
});
return NextResponse.json({ url: portalSession.url });

Enable the portal in your Stripe dashboard under Billing → Customer Portal before going live.


Idempotency Keys

Every Stripe write call should include an idempotency key to prevent duplicate charges on retries. Use a stable combination of operation, user ID, and resource — not Date.now() alone:

stripe.paymentIntents.create(params, { idempotencyKey: `pi-${userId}-${orderId}` });
stripe.subscriptions.create(params, { idempotencyKey: `sub-${userId}-${priceId}` });

Stripe stores keys for 24 hours and returns the original response on duplicate submissions.


Testing with Stripe CLI

# Forward events to local server — no ngrok required
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# Trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted

Test cards:

Card Scenario
4242 4242 4242 4242 Success
4000 0000 0000 0002 Declined
4000 0025 0000 3155 3D Secure
4000 0000 0000 9995 Insufficient funds

For security hardening of your payment routes, see Claude Code Security Scanning.


Common Pitfalls

Raw body parsing. Calling req.json() before stripe.webhooks.constructEvent consumes the raw bytes and breaks HMAC verification. Always read with req.arrayBuffer() and convert to Buffer.

Idempotency keys on retries. A key derived from Date.now() changes on each retry, defeating the purpose. Use operation-userId-resourceId as the key pattern.

Stripe CLI for local testing. Manually curl-ing your endpoint skips signature verification. Run stripe listen --forward-to to test the full verification path locally before deploying.


Want the full Stripe integration prompt pack — checkout, subscriptions, portal, and webhook testing all pre-written?

P1 Power Prompts 300 includes 300 production-tested Claude Code prompts including the complete Stripe payment flow from this guide.

Get P1 Power Prompts 300 ($29) →


Frequently Asked Questions

Why does webhook signature verification always fail?

The most common cause is body parsing. Use req.arrayBuffer() — not req.json() — and convert to Buffer before calling constructEvent. Also confirm STRIPE_WEBHOOK_SECRET matches the value printed by stripe listen --print-secret, not the Stripe dashboard secret (they differ for local vs. production).

What is the difference between cancel_at_period_end and canceling immediately?

cancel_at_period_end: true keeps the subscription active until billing period end — users retain access, no refund issued. stripe.subscriptions.cancel(id) terminates immediately and may trigger a prorated refund. For SaaS, cancel_at_period_end is standard.

How do I reconcile subscription status if a webhook is missed?

Stripe retries failed deliveries for 72 hours with exponential backoff. For extra resilience, run a nightly job that calls stripe.subscriptions.list and compares statuses against your DB. Also handle invoice.payment_failed to mark subscriptions past_due in real time.

Do I need idempotency keys on a single-server setup?

Yes. A network error can cause a request to succeed at Stripe's end while your server never receives the 200. On retry without an idempotency key, a duplicate charge is created. Idempotency keys are one line and prevent a class of bugs that are expensive to reverse.

How do I preview proration charges before an upgrade?

Call stripe.invoices.retrieveUpcoming with the new price ID before applying the change. It returns the exact charge or credit for the current billing period — show this in a confirmation dialog before calling stripe.subscriptions.update.

Tools and references