Stripe Payments skill for Claude Code
Webhook-driven checkout that never double-charges. Claude builds Stripe flows the way payment engineers do: prices resolved on the server, one Customer per account, idempotency keys tied to the business operation, and fulfillment driven only by signature-verified webhooks that tolerate retries and out-of-order delivery. It then proves the flow with Stripe CLI triggers, decline and 3DS cards, and test clocks.
- name
stripe-payments - size253 lines · ≈2,850 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Integrates Stripe payments end to end with Checkout Sessions, Payment Intents, Billing subscriptions, the customer portal, signed webhooks, idempotency keys, and test-mode verification with the Stripe CLI and test clocks. Use when adding checkout or a pricing page, wiring subscriptions or trials, handling Stripe webhook events, fixing double charges or missed fulfillment, or reviewing code that calls the Stripe API. Try asking
- Add Stripe Checkout for our Pro monthly and yearly plans with a customer portal
- Some customers paid but never got access — audit our Stripe webhook handler
- Make our charge endpoint safe to retry without creating duplicate payments
Use it when
- Adding one-off payments, subscriptions, or trials with Stripe
- Writing or fixing a Stripe webhook endpoint
- Customers report double charges, missing access, or stale plan status
- Upgrading the Stripe SDK or API version
Skip it when
- You need generic webhook receiving patterns for a non-Stripe provider — use webhook-handling instead
- The question is retry and idempotency design in general — use idempotency-retries instead
How to check the result
Every handled event returns 200 under stripe listen, a tampered payload gets 400, a replayed event fulfills once, and DB state matches the Dashboard after success, 3DS, and decline card runs.
The common mistake it prevents
Mounting a JSON body parser before the webhook route alters the raw body, so every signature check fails and teams disable verification to make it work.
Done well means: money moves only through server-created objects, fulfillment is driven by verified webhooks (never by the redirect), every mutating call is idempotent, and the whole flow has been exercised in test mode with the Stripe CLI, including failure cards.
Ground rules
- Secret keys live in env vars or a secret manager (
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET). Never commitsk_live_/sk_test_/whsec_values; onlypk_keys reach the browser. - Prices, amounts, and currencies are decided on the server from your catalog. Never accept an amount or price ID from the client without checking it against an allowlist.
- Amounts are integers in the smallest currency unit (
1999= $19.99). Zero-decimal currencies (JPY, KRW) take whole units. Never use floats for money. - The success redirect is a UX hint, not proof of payment. Grant access from webhooks.
- Pin the API version on the client so an SDK upgrade does not silently change payloads.
- Prefer restricted keys (
rk_) scoped to the resources a service actually touches.
Choose the integration
| Need | Use | Why |
|---|---|---|
| One-off or subscription purchase, fastest path | Checkout Session (hosted or ui_mode: 'embedded') | Stripe handles SCA/3DS, wallets, tax, coupons, localization |
| Fully custom payment form inside your UI | Payment Intents + Payment Element | Full control; you own more states |
| Recurring billing | Checkout mode: 'subscription', then Billing webhooks | Subscription lifecycle and dunning built in |
| Customers manage card/plan/cancel | Customer portal (billingPortal.sessions) | Do not build plan-change UI unless required |
| Save a card for later off-session charges | Checkout mode: 'setup' or SetupIntent | Gets mandate/3DS done up front |
| Marketplace / payouts to sellers | Connect | Out of scope here; read Connect docs before designing |
Default to Checkout. Move to Payment Element only when a concrete UI requirement forces it.
Workflow
- [ ] 1. Inspect existing Stripe usage, SDK version, API version, env handling
- [ ] 2. Model products/prices in Stripe and map them to app plans
- [ ] 3. Create the Customer mapping (one Stripe customer per app account)
- [ ] 4. Create Checkout/PaymentIntent on the server with idempotency
- [ ] 5. Build the webhook endpoint: raw body, signature check, dedupe, fast 2xx
- [ ] 6. Fulfill / sync entitlements from events
- [ ] 7. Add the customer portal and cancellation paths
- [ ] 8. Test with Stripe CLI, failure cards, and test clocks
- [ ] 9. Go-live checklist
1. Inspect first
grep -rn "stripe" package.json requirements*.txt pyproject.toml 2>/dev/null
grep -rnE "sk_(test|live)_|whsec_" --exclude-dir=node_modules . && echo "SECRET IN SOURCE"
grep -rn "apiVersion\|stripe.api_version" --exclude-dir=node_modules .
If a secret is found in source, stop and tell the user: it must be rolled in the Dashboard, not just deleted from the file.
2. Catalog
- Create Products and Prices in the Dashboard or via a seed script; reference prices by
lookup_keyin code (stripe.prices.list({ lookup_keys: ['pro_monthly'] })) so test and live IDs differ without code changes. - Store in your DB:
stripe_customer_idon the account, and asubscriptionstable withstripe_subscription_id,status,price_id,current_period_end,cancel_at_period_end.
3–4. Server-side session creation (Node, stripe package)
import Stripe from "stripe";
// Pin to the version your account/webhook endpoint uses; check Dashboard > Developers.
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: process.env.STRIPE_API_VERSION as Stripe.LatestApiVersion,
});
// attemptId: generated once per user click (e.g. form nonce), reused on retries
export async function createCheckout(account: Account, lookupKey: string, attemptId: string) {
const [price] = (await stripe.prices.list({ lookup_keys: [lookupKey], active: true })).data;
if (!price) throw new Error(`Unknown plan ${lookupKey}`);
const customerId = account.stripeCustomerId ?? (await stripe.customers.create(
{ email: account.email, metadata: { accountId: account.id } },
{ idempotencyKey: `customer-create-${account.id}` },
)).id;
return stripe.checkout.sessions.create(
{
mode: "subscription",
customer: customerId,
client_reference_id: account.id,
line_items: [{ price: price.id, quantity: 1 }],
subscription_data: { metadata: { accountId: account.id } },
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/pricing`,
allow_promotion_codes: true,
},
{ idempotencyKey: `checkout-${account.id}-${price.id}-${attemptId}` },
);
}
Python equivalent: stripe.checkout.Session.create(..., idempotency_key=...) or the newer
StripeClient(api_key).checkout.sessions.create(params={...}, options={"idempotency_key": ...}).
Idempotency rules:
- Same key + same params within 24 h returns the original result; same key + different params
errors. Derive keys from the business operation (order ID, account + action), not
uuid()per retry, or retries create duplicates. - Every POST that creates or charges gets a key. GETs do not need one.
5. Webhook endpoint
Signature verification needs the exact raw bytes. Body parsers that JSON-decode first break it.
// Next.js App Router: app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (await alreadyProcessed(event.id)) return new Response("ok");
await enqueue(event); // do slow work async; respond within a few seconds
await markProcessed(event.id);
return new Response("ok");
}
// Express: mount BEFORE app.use(express.json())
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler);
# Python
event = stripe.Webhook.construct_event(payload=request.body, sig_header=request.headers["Stripe-Signature"], secret=os.environ["STRIPE_WEBHOOK_SECRET"])
Hard rules for handlers:
- Dedupe on
event.id(unique constraint). Stripe delivers at least once and retries failed deliveries for up to three days in live mode. - Events arrive out of order. Do not trust the payload as current state for subscriptions;
re-fetch (
stripe.subscriptions.retrieve(id)) and upsert, or compareevent.created. - Return 2xx quickly; a timeout counts as failure and triggers retries.
- Subscribe the endpoint only to the events you handle.
6. Events to handle
| Event | Action |
|---|---|
checkout.session.completed | Link client_reference_id to customer; if payment_status === 'paid', fulfill |
checkout.session.async_payment_succeeded / _failed | Fulfill or notify for delayed methods (ACH, SEPA, Boleto) |
customer.subscription.created / updated / deleted | Re-fetch and upsert subscription row; recompute entitlements |
invoice.paid | Extend access for the period; record payment |
invoice.payment_failed | Flag past_due, email the customer, keep access during dunning per policy |
payment_intent.succeeded / payment_intent.payment_failed | Custom Payment Element flows |
charge.dispute.created | Alert a human; collect evidence |
Full subscription state machine, entitlement rules, and API-version payload notes: read references/subscription-lifecycle.md when building or debugging subscriptions.
7. Customer portal
const portal = await stripe.billingPortal.sessions.create({
customer: account.stripeCustomerId,
return_url: `${process.env.APP_URL}/settings/billing`,
});
redirect(portal.url);
Configure allowed plan switches and cancellation behavior in Dashboard > Billing > Customer
portal. The portal emits the same customer.subscription.* events, so no extra code is needed
if step 6 is right.
8. Test in test mode
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe # prints a whsec_ for local use
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
stripe events resend evt_123 # replay one event
Cards to run through every flow:
| Card | Behavior |
|---|---|
4242 4242 4242 4242 | Succeeds |
4000 0025 0000 3155 | Requires 3DS authentication |
4000 0000 0000 9995 | Declined, insufficient funds |
4000 0000 0000 0341 | Attaches to customer, fails on charge (renewal failure) |
Use test clocks (Dashboard > Billing > Test clocks, or the testHelpers.testClocks API) to
advance time through trial end, renewal, and failed-payment retries without waiting.
9. Go-live
- Live keys and a live webhook endpoint with its own
whsec_, separate from test. - Webhook endpoint API version matches the client’s pinned version.
- Radar rules reviewed; statement descriptor set; tax (Stripe Tax or manual) decided.
- Confirm with the user before switching any env to live keys or issuing real refunds.
Gotchas
express.json()mounted globally before the webhook route: every signature check fails with “No signatures found matching the expected signature”.- Using the CLI’s
whsec_in production, or the production secret locally. - Fulfilling on the success page: closed tabs, ad blockers, and back buttons lose orders; refreshes double-fulfill.
- Creating a new Customer per checkout: duplicates break the portal and history. Look up or create once and store the ID.
- Trusting
subscription.status === 'active'alone:trialingshould usually grant access;past_duemay during dunning;incompletemust not. - Upgrading the SDK major version changes the default API version and payload shapes; read the changelog and update the webhook endpoint version in the same release.
- Floating-point totals and client-computed amounts.
- Refunds, cancellations, and live-mode changes are irreversible: require explicit confirmation.
Verification
stripe listen+stripe triggerfor each handled event returns 200 in the CLI output.- Send a request with a tampered body: endpoint returns 400.
- Resend the same event twice: fulfillment happens once.
- Full checkout with
4242..., a 3DS card, and a decline card in the browser; DB state matches Dashboard state after each. - Test clock advanced past trial and a failed renewal: entitlements change as designed.
Output contract
When finishing, report:
**Flow:** Checkout (subscription) → webhook → entitlements table
**Events handled:** checkout.session.completed, customer.subscription.*, invoice.paid, invoice.payment_failed
**Idempotency:** keys derived from <scheme>; events deduped on event.id (unique index)
**Secrets:** STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_API_VERSION (env only)
**Tested:** CLI triggers, 4242 / 3DS / decline cards, test clock renewal failure
**Open items:** tax settings, portal config, live webhook endpoint creation Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/subscription-lifecycle.md
# Subscription lifecycle and entitlements
Read this when building subscription sync or debugging "user paid but has no access" /
"user cancelled but still has access" reports.
## Status meanings and access policy
| `status` | Meaning | Default access |
|---|---|---|
| `incomplete` | First payment not yet confirmed (e.g. 3DS pending) | No |
| `incomplete_expired` | First payment never completed within 23 h | No; treat as dead |
| `trialing` | In free trial | Yes |
| `active` | Paid and current | Yes |
| `past_due` | Renewal payment failed, retries in progress | Yes during grace window, show banner |
| `unpaid` | Retries exhausted, subscription kept open | No |
| `canceled` | Ended | No |
| `paused` | Trial ended without payment method (when configured to pause) | No |
`cancel_at_period_end: true` with `status: active` means the user cancelled but keeps access
until the period ends. Show "Ends on <date>", do not revoke early.
## Sync pattern
Write one function and call it from every subscription-related event:
```ts
async function syncSubscription(subscriptionId: string) {
const sub = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ["items.data.price"],
});
const item = sub.items.data[0];
await db.subscription.upsert({
where: { stripeSubscriptionId: sub.id },
create: toRow(sub, item),
update: toRow(sub, item),
});
await recomputeEntitlements(sub.metadata.accountId);
}
```
Re-fetching makes out-of-order delivery harmless: whichever event arrives last writes the
current state. If you must avoid the extra API call, store `event.created` per subscription
and ignore older events.
## API version notes
- Payload shapes follow the API version of the webhook endpoint, not the SDK. Keep them equal.
- On recent API versions (2025-03-31 "basil" onward) billing period fields
(`current_period_start`, `current_period_end`) are on subscription items rather than the
subscription object. Read `sub.items.data[0].current_period_end` when on those versions;
check the changelog for the exact version pinned in the project.
- When upgrading versions, create the new webhook endpoint version first, deploy code that
tolerates both shapes, then switch.
## Event timeline for a typical subscription
1. `checkout.session.completed` (subscription created, first invoice paid)
2. `customer.subscription.created`, `invoice.created`, `invoice.finalized`, `invoice.paid`
3. Each renewal: `invoice.upcoming` (a few days before), `invoice.created`, `invoice.paid`,
`customer.subscription.updated` (new period)
4. Failed renewal: `invoice.payment_failed`, `customer.subscription.updated` (`past_due`),
then Smart Retries; eventually `invoice.paid` or `customer.subscription.deleted`/`unpaid`
5. Plan change via portal: `customer.subscription.updated` with new price; proration invoice
6. Cancel: `customer.subscription.updated` (`cancel_at_period_end: true`), later
`customer.subscription.deleted`
These can arrive in any order. Never assume `created` precedes `updated`.
## Trials
- `subscription_data: { trial_period_days: 14 }` on Checkout.
- Without collecting a payment method, set `payment_method_collection: 'if_required'` and
configure `trial_settings.end_behavior.missing_payment_method` (`cancel` or `pause`).
- `customer.subscription.trial_will_end` fires three days before; use it for reminder email.
## Proration and plan changes
- Upgrades: `proration_behavior: 'always_invoice'` charges immediately; default
`create_prorations` adds to next invoice.
- Downgrades usually take effect at period end: use a subscription schedule or change at
renewal rather than immediate proration credit, depending on product policy.
## Dunning
Configure Smart Retries and failed-payment emails in Dashboard > Billing > Revenue recovery.
App side: show a banner while `past_due`, link to the customer portal to update the card,
and revoke only on `unpaid`/`canceled`.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.