Build or review a Stripe integration with current Stripe guidance: Checkout Sessions, Payment Element, Setup Intents, subscriptions and the Customer Portal, webhook-driven fulfillment, idempotency, restricted API keys and Stripe Tax. Use when: stripe, payments, checkout, subscription, billing, webhook, refund, payment intent.
Guidance for building payment code that does not lose orders, double-charge or
leak keys. It follows Stripe's own recommendations as of October 2026. When a
detail matters (a parameter, an event name, an API version), check
docs.stripe.com or the Stripe MCP server's
search_stripe_documentation tool before relying on memory.
2026-09-30.endive. Pin it explicitly on the client so
an account default change never alters response shapes under you.stripe.api_key = …, Stripe.setApiKey) is deprecated in every SDK.// lib/stripe.ts
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
apiVersion: '2026-09-30.endive',
});
| Building | Use |
|---|---|
| One-time payment, simple product | Payment Links (no code) or Checkout Sessions |
| One-time payment in your web app | Checkout Sessions (hosted or embedded) |
| Custom payment form | Checkout Sessions with ui_mode: 'custom' + Payment Element |
| Save a card for later | Setup Intents |
| Charge off-session (no customer present) | Payment Intents with a saved payment method |
| Subscriptions, seats, trials | Billing APIs + Checkout mode: 'subscription' + Customer Portal |
| New usage-based billing | Metronome (Stripe's usage platform), not raw Billing Meters |
| Marketplace or platform | Connect, Accounts v2 (/v2/core/accounts), Stripe-hosted onboarding |
Never use the Charges API, Sources API, Tokens API, Card Element or the
plan object. They are deprecated or legacy; use the rows above and Prices.
A customer can pay and close the tab before the success page loads. Grant access, send email and write orders only in the webhook handler.
checkout.session.completed and
checkout.session.async_payment_succeeded. Fulfill only when
session.payment_status !== 'unpaid'. Delayed methods (bank debits) send
completed while still unpaid.checkout.session.async_payment_failed for those failures.customer.subscription.created|updated|deleted,
invoice.paid and invoice.payment_failed. Renewals, dunning and
cancellations only reach you this way.Signature verification needs the exact bytes Stripe sent. Body-parsing middleware breaks it.
// Next.js App Router: app/api/stripe/webhook/route.ts
import Stripe from 'stripe';
import { stripe } from '@/lib/stripe';
export async function POST(req: Request) {
const body = await req.text(); // raw body, do not use req.json()
const signature = req.headers.get('stripe-signature');
if (!signature) return new Response('missing signature', { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response('invalid signature', { status: 400 });
}
switch (event.type) {
case 'checkout.session.completed':
case 'checkout.session.async_payment_succeeded': {
// Re-fetch instead of trusting the payload snapshot.
const session = await stripe.checkout.sessions.retrieve(event.data.object.id);
// fulfillOrder must be idempotent: the same event can arrive twice.
if (session.payment_status !== 'unpaid') await fulfillOrder(session.id);
break;
}
case 'checkout.session.async_payment_failed':
await markPaymentFailed(event.data.object.id);
break;
}
return new Response(null, { status: 200 });
}
await stripe.webhooks.constructEventAsync(...).express.raw({ type: 'application/json' }) on the webhook
route only, before any global express.json().fulfillOrder idempotent (store processed event or
session IDs with a unique constraint) and decide by the object's current
state, not by event order.A network retry without one can create a second charge or subscription.
await stripe.paymentIntents.create(
{ amount: 2000, currency: 'usd', customer: customerId },
{ idempotencyKey: `order-${orderId}-charge` },
);
Derive the key from your own record (order ID, user ID + action), not from a random value per attempt, or retries get new keys.
Do not pass payment_method_types (not even ['card']). Omitting it turns on
dynamic payment methods, configured in the Dashboard. To narrow the list use
payment_method_configuration, excluded_payment_method_types, or
allowed_payment_method_types on Payment and Setup Intents. The one exception
is Terminal: payment_method_types: ['card_present'].
Store the Stripe Customer, Subscription and Checkout Session IDs on your own
rows, and resolve webhook events through those IDs. Use metadata (for
example client_reference_id or metadata.orderId on the session) as a
fallback, not as the primary link.
Read access rights from your database, updated by the subscription webhooks above, and re-fetch the subscription from Stripe when a decision depends on it. Use the Customer Portal for upgrades, downgrades, cancellation and card updates instead of building those screens. Create one Product per plan and attach multiple Prices only for variants of the same plan (monthly, yearly, currency). Do not build renewal loops on raw Payment Intents; Billing handles retries and dunning (configure Smart Retries and emails in the Dashboard).
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 },
success_url: `${baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/pricing`,
});
automatic_tax: { enabled: true } collects nothing, and returns no error,
until the account has an active tax registration for the customer's
jurisdiction. Check registrations before telling anyone tax is on.
rk_) with only the permissions each service
needs over full secret keys (sk_). Use separate keys per environment.[sr]k_live_ in a repo are live keys: roll them immediately.whsec_) like an API key.sk_ and rk_ strings.https://*.stripe.com in
script-src, frame-src and connect-src.state value.stripe sandbox create makes one without signing up.stripe listen --forward-to localhost:3000/api/stripe/webhook
(prints the whsec_ to use), then stripe trigger checkout.session.completed.4242 4242 4242 4242 succeeds, 4000 0025 0000 3155 requires
3D Secure, 4000 0000 0000 9995 is declined (insufficient funds).https://mcp.stripe.com, OAuth) gives the agent account
data and doc search: npx claude-code-templates@latest --mcp devtools/stripe.npx claude-code-templates@latest --setting permissions/stripe-mcp-confirm-writes.atlas_company_formation) or the CLI
(stripe plugin install tools, then stripe tools create_atlas_application).
Atlas validates every value; identity, payment, signatures and submission
happen in the Stripe Dashboard. Record the founders' decisions on entity
type, equity and vesting; never choose them for the user.npx skills add https://docs.stripe.com
or stripe agent setup), including stripe-best-practices and upgrade-stripe.Works well with: nextjs-supabase-auth, nextjs-app-router-patterns, supabase-postgres-best-practices, api-security-best-practices
Copy a source-pinned command for your client. You run it yourself.
Destination: .claude/skills/stripe-integration · pinned to the source commit
# Run from your project root
git clone https://github.com/davila7/claude-code-templates.git .skillboard-tmp
git -C .skillboard-tmp checkout 5cc17cf267132cc690dee82239b80a99ec82e4f8
mkdir -p ".claude/skills"
cp -r ".skillboard-tmp/cli-tool/components/skills/development/stripe-integration" ".claude/skills/"
rm -rf .skillboard-tmpReview the source before running. This copies files into your project; it is not a one-click install and does not verify runtime safety.
sudo apt update && sudo apt install -y gitnpm install -g @anthropic-ai/claude-code# Run from your project root
git clone https://github.com/davila7/claude-code-templates.git .skillboard-tmp
git -C .skillboard-tmp checkout 5cc17cf267132cc690dee82239b80a99ec82e4f8
mkdir -p ".claude/skills"
cp -r ".skillboard-tmp/cli-tool/components/skills/development/stripe-integration" ".claude/skills/"
rm -rf .skillboard-tmpDestination: .claude/skills/stripe-integration
Scanner static-checks@0.1.0 · commit 5cc17cf26713. Static checks cannot prove runtime safety – review the source and the exact diff before installing. How checks work.
References credentials, tokens or secret files that a skill should not need.
Evidence: [redacted]· fingerprint 2e9bc6c94a4cbdfe