Integrations No. 421Editor's pick

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.

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

~/.claude/skills/stripe-payments/SKILL.md

--- name: stripe-payments description: Integrates Stripe payments end to end with Checkout Sessions, Payment Intents, Billing subscriptions, the customer portal, signed webhooks,… license: MIT ---

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 commit sk_live_/sk_test_/whsec_ values; only pk_ 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

NeedUseWhy
One-off or subscription purchase, fastest pathCheckout Session (hosted or ui_mode: 'embedded')Stripe handles SCA/3DS, wallets, tax, coupons, localization
Fully custom payment form inside your UIPayment Intents + Payment ElementFull control; you own more states
Recurring billingCheckout mode: 'subscription', then Billing webhooksSubscription lifecycle and dunning built in
Customers manage card/plan/cancelCustomer portal (billingPortal.sessions)Do not build plan-change UI unless required
Save a card for later off-session chargesCheckout mode: 'setup' or SetupIntentGets mandate/3DS done up front
Marketplace / payouts to sellersConnectOut 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_key in code (stripe.prices.list({ lookup_keys: ['pro_monthly'] })) so test and live IDs differ without code changes.
  • Store in your DB: stripe_customer_id on the account, and a subscriptions table with stripe_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 compare event.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

EventAction
checkout.session.completedLink client_reference_id to customer; if payment_status === 'paid', fulfill
checkout.session.async_payment_succeeded / _failedFulfill or notify for delayed methods (ACH, SEPA, Boleto)
customer.subscription.created / updated / deletedRe-fetch and upsert subscription row; recompute entitlements
invoice.paidExtend access for the period; record payment
invoice.payment_failedFlag past_due, email the customer, keep access during dunning per policy
payment_intent.succeeded / payment_intent.payment_failedCustom Payment Element flows
charge.dispute.createdAlert 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:

CardBehavior
4242 4242 4242 4242Succeeds
4000 0025 0000 3155Requires 3DS authentication
4000 0000 0000 9995Declined, insufficient funds
4000 0000 0000 0341Attaches 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: trialing should usually grant access; past_due may during dunning; incomplete must 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

  1. stripe listen + stripe trigger for each handled event returns 200 in the CLI output.
  2. Send a request with a tampered body: endpoint returns 400.
  3. Resend the same event twice: fulfillment happens once.
  4. Full checkout with 4242..., a 3DS card, and a decline card in the browser; DB state matches Dashboard state after each.
  5. 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 87 lines · 3.9 KB
# 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.

esc