Stripe is the standard for payment processing, but getting it right requires attention to edge cases: webhook signature verification, idempotency keys, SCA compliance, and handling failed payments gracefully. Claude Code generates Stripe integration code that handles these requirements — not just happy path flows.
This guide covers Stripe with Claude Code: one-time payments, subscriptions, webhook handling, customer portal, and testing.
One-Time Payments with Checkout
Implement Stripe Checkout for a one-time purchase.
After payment, redirect to a success page and fulfill the order.
// Create Checkout Session
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2025-01-27.acacia',
typescript: true,
});
// API route handler (Next.js, Express, etc.)
export async function createCheckoutSession(req: Request) {
const { items, customerId } = await req.json();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer: customerId, // Pre-fill if existing customer
line_items: items.map(item => ({
price_data: {
currency: 'usd',
product_data: {
name: item.name,
description: item.description,
images: [item.imageUrl],
metadata: { productId: item.id },
},
unit_amount: item.priceCents, // Stripe uses cents
},
quantity: item.quantity,
})),
success_url: `${process.env.APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/cart`,
// Collect billing address for tax calculation
billing_address_collection: 'auto',
// Automatic tax (requires product tax codes)
automatic_tax: { enabled: true },
// Metadata for webhook processing
metadata: {
orderId: crypto.randomUUID(),
userId: req.headers.get('x-user-id') ?? '',
},
});
return Response.json({ url: session.url });
}
// Success page — verify payment before fulfilling
export async function verifyAndFulfill(sessionId: string) {
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['payment_intent', 'line_items'],
});
if (session.payment_status !== 'paid') {
throw new Error('Payment not completed');
}
// Check idempotency — don't fulfill twice
const existingOrder = await db('orders').where('stripe_session_id', sessionId).first();
if (existingOrder?.fulfilled_at) {
return existingOrder; // Already fulfilled
}
// Fulfill order
const order = await fulfillOrder({
stripeSessionId: sessionId,
userId: session.metadata?.userId,
items: session.line_items?.data ?? [],
amountCents: session.amount_total ?? 0,
});
return order;
}
CLAUDE.md for Stripe Projects
## Stripe Integration
- Live keys: STRIPE_SECRET_KEY, STRIPE_PUBLISHABLE_KEY in env
- Webhook secret: STRIPE_WEBHOOK_SECRET (validate all webhooks)
- Test mode: use STRIPE_SECRET_KEY=sk_test_... locally
- Payment link (active $39): https://buy.stripe.com/... — never hardcode
- Idempotency keys: required for all payment create/finalize operations
- Always subscribe to: checkout.session.completed, payment_intent.payment_failed,
customer.subscription.updated, customer.subscription.deleted
- Error handling: catch Stripe errors, return 400 for card errors (expected), 500 for API errors
Webhook Handling
Set up a webhook handler that processes Stripe events.
It needs to verify the signature, handle each event type,
and be idempotent — replaying an event shouldn't create duplicate records.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function handleWebhook(req: Request) {
const body = await req.text(); // Raw body required for signature verification
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
// Invalid signature — reject
return new Response(`Webhook signature verification failed: ${err}`, { status: 400 });
}
// Idempotency check — mark event as processing to prevent duplicates
const alreadyProcessed = await db('stripe_events')
.where('stripe_event_id', event.id)
.first();
if (alreadyProcessed) {
return new Response('Already processed', { status: 200 });
}
// Insert event record before processing (prevents duplicate processing on crash/retry)
await db('stripe_events').insert({
stripe_event_id: event.id,
event_type: event.type,
processed_at: new Date(),
});
try {
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await onCheckoutCompleted(session);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object as Stripe.Subscription;
await syncSubscription(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription;
await cancelSubscription(subscription.id);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await handleFailedPayment(invoice);
break;
}
default:
// Log unknown events for monitoring — don't error
console.log(`Unhandled Stripe event type: ${event.type}`);
}
return new Response('OK', { status: 200 });
} catch (error) {
// Mark as failed so it can be retried
await db('stripe_events')
.where('stripe_event_id', event.id)
.update({ error: (error as Error).message });
// Return 500 — Stripe will retry the webhook
return new Response('Processing failed', { status: 500 });
}
}
Subscriptions
Implement subscription billing with three tiers: Free, Pro ($29/mo), Enterprise ($99/mo).
Upgrades should take effect immediately, downgrades at period end.
// Create subscription for new customer
async function createSubscription(customerId: string, priceId: string) {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
payment_behavior: 'default_incomplete', // Don't charge until confirmed
payment_settings: {
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
return {
subscriptionId: subscription.id,
clientSecret: (subscription.latest_invoice as Stripe.Invoice)
?.payment_intent
? ((subscription.latest_invoice as Stripe.Invoice).payment_intent as Stripe.PaymentIntent).client_secret
: null,
};
}
// Upgrade — prorate immediately
async function upgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
return stripe.subscriptions.update(subscriptionId, {
items: [{
id: currentItem.id,
price: newPriceId,
}],
proration_behavior: 'always_invoice', // Charge immediately for the upgrade
});
}
// Downgrade — take effect at period end
async function downgradeSubscription(subscriptionId: string, newPriceId: string) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const currentItem = subscription.items.data[0];
return stripe.subscriptions.update(subscriptionId, {
items: [{
id: currentItem.id,
price: newPriceId,
}],
proration_behavior: 'none', // No immediate charge
billing_cycle_anchor: 'unchanged', // Keep current period
});
}
Customer Portal
Add Stripe's customer portal so users can manage their
own subscriptions, update payment methods, and view invoices.
// Create a portal session and redirect
export async function createPortalSession(customerId: string, returnUrl: string) {
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
// Configure what customers can do in the portal
// (configure in Stripe Dashboard → Customer portal settings)
});
return portalSession.url;
}
// In your route handler:
app.post('/customer-portal', authenticate, async (req, res) => {
const user = await User.findById(req.user.id);
if (!user.stripeCustomerId) {
return res.status(400).json({ error: 'No Stripe customer found' });
}
const url = await createPortalSession(
user.stripeCustomerId,
`${process.env.APP_URL}/settings`,
);
res.json({ url });
});
The customer portal handles card updates, subscription cancellations, and invoice downloads — saving significant development time for billing UI.
Testing Stripe Locally
How do I test webhooks locally without deploying?
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login and forward webhooks to local server
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal — trigger specific events without paying:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed
# Test cards (work in test mode):
# 4242424242424242 — Visa, succeeds
# 4000000000000002 — card declined
# 4000002500003155 — requires 3D Secure (SCA)
# 4000000000009995 — insufficient funds
// Integration test with Stripe test mode
describe('checkout webhook', () => {
it('fulfills order after successful payment', async () => {
// Create a real checkout session in test mode
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: 'price_test_xxx', quantity: 1 }],
success_url: 'https://test.com/success',
cancel_url: 'https://test.com/cancel',
});
// Simulate the webhook event
const event = stripe.webhooks.generateTestHeaderString({
payload: JSON.stringify({
type: 'checkout.session.completed',
data: { object: { ...session, payment_status: 'paid' } },
}),
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
const response = await request(app)
.post('/api/webhooks/stripe')
.set('stripe-signature', event.header)
.send(event.payload);
expect(response.status).toBe(200);
// Verify fulfillment happened
const order = await db('orders').where('stripe_session_id', session.id).first();
expect(order).toBeDefined();
});
});
For authentication and user management that integrates with Stripe customers, see the authentication guide. For observability into payment flows — tracking conversion rates, failure rates, and revenue metrics — see the observability guide. The Claude Skills 360 bundle includes Stripe integration skill sets for checkout flows, subscription management, and billing edge cases. Start with the free tier to try payment integration code generation.