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.
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.
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.