Adding Stripe Payments With Claude Code
Wire up Stripe Checkout, webhooks, and the customer portal in a Next.js app using Claude Code. From first prompt to live payment in one session.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.
Problem: Stripe integration has 7 moving parts: checkout, webhooks, the customer portal, environment variables, test keys, live keys, and the CLI. Most tutorials cover three of them and leave you debugging the rest at midnight.
Quick Win: Add the Stripe MCP server to Claude Code in one command so Claude can look up customers, prices, and subscriptions in real time while you build:
claude mcp add --transport http stripe https://mcp.stripe.com/That single line gives Claude direct access to your Stripe account. It can read live data, verify your products exist, and catch config errors before they hit your webhook handler.
What You Need Before Starting
Three things need to be in place.
A Stripe account with at least one product and price created. You can have Claude create them via the MCP server without touching the Dashboard. Once the MCP is connected, a prompt like "create a product called Pro Plan with a $29/month recurring price" is enough.
Claude Code with the Stripe MCP connected (command above). The alternative is the Stripe best-practices skill, which injects Stripe documentation into your session:
npx skills add https://docs.stripe.com --yesThe MCP server is the better choice for active development. Claude can call it mid-session to list your prices, look up a customer by email, retrieve a subscription's current status, or verify that your webhook secret matches what the Dashboard shows. The skill gives you documentation context only. It does not query your account.
The two are not mutually exclusive. Some teams run both: MCP for live queries, skill for best-practice guidance. The MCP with a restricted API key (rk_test_...) is the safer setup in test mode because restricted keys limit which Stripe objects Claude can touch.
Four environment variables in your .env.local file:
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_URL=http://localhost:3000STRIPE_SECRET_KEY never touches the client. NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY is safe to expose. STRIPE_WEBHOOK_SECRET comes from the Stripe CLI when you start local event forwarding (covered in the testing section). NEXT_PUBLIC_URL feeds your success_url and return_url parameters.
Checkout Sessions vs. Payment Intents
Two Stripe APIs can take a payment. Picking the wrong one adds weeks of work.
Checkout Sessions (stripe.checkout.sessions.create()) redirect the user to a Stripe-hosted page or an embedded form on your domain. Stripe handles card input, 3D Secure, Apple Pay, currency display, and tax. For most SaaS products, this is the right choice. One-time purchases and subscriptions both run through it.
Payment Intents (stripe.paymentIntents.create()) are lower-level. You build the form yourself with Stripe Elements, collect card data, and call the API. More control, more code, more maintenance. Use this only when you need a fully custom checkout UI that a hosted page cannot deliver.
The 2026 preferred pattern for Checkout Sessions is Embedded Checkout: ui_mode: 'embedded' keeps the user on your domain, uses EmbeddedCheckoutProvider and EmbeddedCheckout components from @stripe/react-stripe-js, and returns to a return_url you set. Hosted Checkout (redirect to stripe.com) still works, but Embedded is what Stripe steers new integrations toward.
Building the Checkout Flow
Ask Claude to scaffold the route, then verify the mode param before shipping.
A useful prompt to give Claude here is: "Build me a Stripe Checkout Sessions route in app/api/checkout/route.ts. Use mode: payment for one-time purchases, return the session URL as JSON, and read all URLs from environment variables."
For a one-time purchase, the route looks like this:
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
})
export async function POST(request: Request) {
const { priceId } = await request.json()
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
})
return NextResponse.json({ url: session.url })
}For a subscription, change exactly one thing. Set mode: 'subscription' instead of mode: 'payment'. Your priceId must point to a recurring price (not a one-time price) in Stripe. Everything else in the route stays identical.
To switch to Embedded Checkout, add ui_mode: 'embedded' and replace success_url with return_url. The response returns session.client_secret instead of session.url, which the EmbeddedCheckoutProvider uses on the frontend.
Webhooks: The Part Everyone Gets Wrong
Webhooks are where Stripe talks back to your app. Every provisioning decision (grant access, revoke access, send a receipt) should live in the webhook handler, not in the checkout success redirect. Users close tabs and hit the back button. The redirect is unreliable. The webhook always fires.
The critical issue in Next.js App Router is raw body parsing. Stripe's signature verification (stripe.webhooks.constructEvent) requires the raw request body as a string. App Router parses the body before your handler sees it. If you call request.json(), the stream is consumed and verification fails every time.
Give Claude the exact constraint: "Build a Stripe webhook handler at app/api/stripe-webhook/route.ts. It must use request.text() for the raw body, not request.json(), and verify the signature before processing any events."
Use request.text() instead:
// app/api/stripe-webhook/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
})
export async function POST(request: Request) {
const rawBody = await request.text()
const sig = request.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json(
{ error: 'Webhook signature verification failed' },
{ status: 400 }
)
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
// Provision access, send welcome email
await grantAccess(session.customer as string)
break
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription
// Update tier in DB
await updateSubscription(sub.customer as string, sub.status)
break
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription
// Revoke access
await revokeAccess(sub.customer as string)
break
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice
// Send dunning email, flag in DB
await handleFailedPayment(invoice.customer as string)
break
}
}
return NextResponse.json({ received: true })
}The four events to handle at minimum are in the table below. The rest can be added as your billing logic grows.
| Event | When it fires | Action |
|---|---|---|
checkout.session.completed | Payment or subscription started | Provision access, send welcome email |
customer.subscription.updated | Plan change, renewal, trial end | Update DB tier |
customer.subscription.deleted | Cancellation | Revoke access |
invoice.payment_failed | Recurring charge failed | Send dunning email, flag in DB |
One more thing: this route must be excluded from any Next.js middleware that reads the body, and it must not be wrapped in your app's auth checks. Stripe posts to it as an unauthenticated request. Protect it with signature verification only.
Customer Portal Setup
The customer portal lets users manage their own subscription: cancel, update payment info, view invoices. One step trips up most developers.
You must configure the portal in the Stripe Dashboard before creating any portal sessions. Without a saved configuration, every billingPortal.sessions.create() call returns an error. The configuration URL for test mode is https://dashboard.stripe.com/test/settings/billing/portal. Live mode has its own separate URL at https://dashboard.stripe.com/settings/billing/portal. Both require configuration before you deploy.
The configuration step is where you decide what actions users are allowed to take: cancel their subscription, update their payment method, switch plans, download invoices. Set those options in the Dashboard, save the configuration, and then your API calls will work.
Once configured, the route to create a session is short:
// app/api/portal/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
})
export async function POST(request: Request) {
const { customerId } = await request.json()
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_URL}/account`,
})
return NextResponse.json({ url: session.url })
}Your frontend redirects to session.url. The user sees their Stripe billing page, makes changes, and returns to /account when done. The changes fire as customer.subscription.updated or customer.subscription.deleted events, which your webhook handler already covers.
Note: test mode and live mode portal configurations are completely separate. Configuring the portal in test mode does not set it up for live mode. Go through the same configuration steps in both environments before going live.
Testing the Full Flow
The Stripe CLI forwards live events from Stripe to your local server. Run this in a separate terminal before testing any payments:
stripe listen --forward-to localhost:3000/api/stripe-webhookThe CLI prints a webhook signing secret when it starts. That value goes in STRIPE_WEBHOOK_SECRET in your .env.local. It changes every time you restart the listener, so don't hardcode it.
To fire a specific event without clicking through a payment form, use stripe trigger:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_failedTest cards for different scenarios:
| Scenario | Card number |
|---|---|
| Successful payment | 4242 4242 4242 4242 |
| Declined | 4000 0000 0000 0002 |
| 3D Secure required | 4000 0025 0000 3155 |
| Insufficient funds | 4000 0000 0000 9995 |
Use any future expiry date, any 3-digit CVC, and any ZIP code. The card number is the only value that changes the outcome.
The recommended test sequence is: start the CLI listener, open your checkout page, complete a payment with card 4242 4242 4242 4242, watch the checkout.session.completed event arrive in your terminal, and verify your handler ran (check the DB or logs). Then trigger stripe trigger invoice.payment_failed to confirm your dunning path works without waiting for a real failed charge. Cover the 3D Secure card (4000 0025 0000 3155) last. Stripe redirects through an authentication step that your frontend must handle. If the redirect fails silently, the session never completes and no webhook fires.
Going Live Checklist
Switching from test to live mode is four steps, not one key swap.
First, replace your test keys (sk_test_, pk_test_) with live keys (sk_live_, pk_live_) in your production environment variables.
Second, configure the customer portal in live mode at https://dashboard.stripe.com/settings/billing/portal. Test mode and live mode have completely separate portal configurations. What you set in test mode does not carry over.
Third, set your webhook endpoint in the Stripe Dashboard to your production URL. Go to Developers > Webhooks > Add endpoint and point it at https://yourdomain.com/api/stripe-webhook. Copy the live webhook signing secret and set it as STRIPE_WEBHOOK_SECRET in production.
Fourth, confirm your price IDs. Test mode prices (price_test_...) do not exist in live mode. Any hardcoded price IDs need to be updated to live price IDs or read from environment variables.
Run one real payment with a real card (then refund it) before calling the integration done. The CLI trigger is good for testing handlers, but nothing confirms the full path like an actual charge.
What Build This Now Ships Pre-Built
Writing and debugging this flow takes a full day for most developers. Stripe's checkout edge function, the webhook handler, the customer portal route, and all four environment variables are pre-wired in Build This Now from day one. The integration deploys to Supabase Edge Functions, tested, and working before you write your first custom feature.
Stripe is not the hard part. Getting every piece connected in the right order is. Once you've done it once, you know where the gaps are.
Stop configuring. Start building.
SaaS builder templates with AI orchestration.