stripe-billing · diff
git:20260909.ebab4a1 to git:20260914.ca2da3a
17 added, 2 removed. Audit A to A.
---
name: stripe-billing
description: >-
Use when connecting a product to Stripe, or auditing a live integration:
checkout, renewals, seats, proration, refunds, cancellation and the
coupon offered at the cancel step, the portal, and the webhook that
turns a payment into an entitlement in your database. Covers Stripe's
agent toolchain, the pinned API version and SDK retries, price
- resolution across modes, claim-first webhook idempotency, what invoice
+ resolution, claim-first webhook idempotency, what invoice
billing_reason decides, cumulative refunds, the cancellation field
flexible billing_mode moved, retention eligibility Stripe cannot
express, and write ordering with compensating reverts. Triggers - "add
Stripe", "Stripe checkout", "subscription billing", "webhook signature",
"invoice.paid", "proration", "refund", "cancel subscription", "retention
coupon", "подключить Stripe", "оплата подпиской", "вебхук Stripe",
- "скидка при отмене", "биллинг". Not for choosing between Stripe products
+ "скидка при отмене", "биллинг подписок". Not for choosing between Stripe products
(stripe-best-practices) or reading Stripe docs (stripe-docs).
license: MIT
---
# Stripe billing
Stripe holds the money. Your database holds the entitlement. **Every serious
billing defect is one fact living in two systems that stopped agreeing** — a
price, a quantity, a period, a refunded total.
Stripe's API is not where integrations fail. Failures happen at the seam: the
callback that arrived twice, the renewal that granted a month of product for a
$0.40 proration invoice, the upgrade that charged the card and then failed to
write the row.
This skill is that seam. For **which** Stripe primitive to use, the official
`stripe-best-practices` skill is the authority and wins any disagreement; for
lookups, `stripe-docs`. Examples are TypeScript; the rules are
language-neutral.
Deep material, loaded on demand:
| Read | When |
|---|---|
| [`references/stripe-agent-toolchain.md`](references/stripe-agent-toolchain.md) | starting from nothing, or about to guess an API shape — CLI, MCP, skills index, key handling |
| [`references/webhook-events.md`](references/webhook-events.md) | writing or reviewing the handler — event catalogue, payload shapes, ordering, failure semantics |
| [`references/subscription-lifecycle.md`](references/subscription-lifecycle.md) | implementing checkout, verify, renewal, seats, plan change, trials, clawback |
| [`references/cancellation-and-retention.md`](references/cancellation-and-retention.md) | implementing cancel or reactivation, or offering a coupon at the cancel step |
| [`references/price-integrity.md`](references/price-integrity.md) | pricing lives in more than one file, or an advertised price must be proved against Stripe |
| [`references/testing-and-local-dev.md`](references/testing-and-local-dev.md) | local webhooks and mocks, and the shipped `fixtures/` — the suite, already written |
| [`references/provider-concentration.md`](references/provider-concentration.md) | growing revenue, opening a second market, separating involuntary churn, or if the payment account is limited |
---
## Start with Stripe's own tooling
Stripe ships its own MCP server, CLI and agent skills; the API moves monthly, so
**reach for them first**. This skill owns the seams they do not: which signal is
the payment, what the redirect proves, and where money gets counted twice. Commands
in `references/stripe-agent-toolchain.md`.
## The two ledgers
```
browser your server Stripe
│ choose plan ──────────►│
│ │ create checkout session ──►│
│◄──────── url ───────────│◄──── session + hosted page ─│
│ pays on Stripe's page ─┼────────────────────────────►│
│◄─ redirect (proves nothing) ──────────────────────── │
│ │◄──── webhook (at-least-once)│
│ │ write entitlement │
│ │◄──── webhook (again, later) │
└── nightly: reconcile ─────►│
```
Amounts, tax, invoices, dunning and payment methods are **Stripe's**. Who may
use what, how many seats and what credit was granted are **yours**. The link
between them is ids, and nothing else.
**One home per fact.** Copy an amount out of Stripe into your code and you own
the drift — silently, because checkout sends a price id and Stripe holds the
number. A wrong amount never fails a request; it is only ever shown to
customers. See [`references/price-integrity.md`](references/price-integrity.md).
---
## The client
```ts
let client: Stripe | null = null;
export function getStripe(): Stripe {
if (!client) {
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
// Pin checked 2026-08-30 — 2026-08-26.dahlia was already newer. Confirm
// against Stripe's changelog before pinning; this line goes stale monthly.
client = new Stripe(key, { apiVersion: "2026-07-29.dahlia", maxNetworkRetries: 2 });
}
return client;
}
```
- **Lazy, not module-level.** A `new Stripe(...)` at import time crashes every
build step that imports the module without a key.
- **Pin `apiVersion`.** Unpinned, response shapes change on Stripe's schedule
rather than yours. Upgrades are a task (`stripe:upgrade-stripe`), not a
deploy-day surprise.
- **`maxNetworkRetries`, never a hand-rolled loop.** The SDK generates an
idempotency key per request, which is the only thing that makes retrying a
*write* safe. A loop around `subscriptions.create` buys two subscriptions for
one intent. For deliberate retries, pass your own stable `{ idempotencyKey }`.
---
## Products, prices, two modes
Checkout takes a **price**; your code should speak **product**. Prices are
replaced when you reprice; products are stable.
- **One Product per plan a customer can choose.** Several Prices on one Product
only for variants of the same plan (monthly vs annual, per currency). Tiers
sharing a Product make every invoice line read the same name and destroy the
product-id → plan mapping the rest of your code depends on.
- **Pin price ids in configuration, keep `prices.list({ active: true })` as the
fallback.** After a reprice a product has two active prices, in an order
nobody promised.
- **Validation allowlists need ids from both modes.** Your database holds rows
written in test and rows written in live; a webhook checking "do we sell this"
against only the current mode rejects real history.
- **`resource_missing` almost always means mode mismatch.** Catch it and say so,
naming the mode and the key prefix — raw, it reads as "product deleted".
---
## Get-or-create customer is a race
Two tabs, two requests, two customers, and the second silently owns the
subscription the first is charging. Create, then claim the id with a conditional
update; if the update matched no row you lost the race, so delete the orphan
customer and read the winner's id. Before creating, retrieve the stored id and
treat `resource_missing` or `deleted: true` as "create a new one" — the normal
state after a key rotation. The code is in
[`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
---
## Checkout session
```ts
const metadata = { userId, productId, quantity: String(qty) };
await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [{ price: priceId, quantity: qty }],
// Never pass payment_method_types — Stripe picks eligible methods from
// Dashboard settings; hardcoding ['card'] locks out methods that convert.
metadata, // reaches checkout.session.completed
subscription_data: { metadata }, // reaches EVERY later subscription event
success_url: `${origin}/after?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${origin}/plans`,
});
```
1. **Write metadata twice.** Session metadata does not propagate to the
subscription. Renewal invoices and every `customer.subscription.*` event
carry the subscription — a year later the session is gone and the only
`userId` you have is the one in `subscription_data.metadata`.
2. **Validate caller-supplied return URLs against your own origin.** An endpoint
that passes a body-supplied `successUrl` through is an open redirect wearing
a payment flow.
3. **Guard duplicates before the money moves**, not after: an active
subscription to the same product answers 409 with the existing id. A second
active subscription for one seat is a refund conversation.
`{CHECKOUT_SESSION_ID}` is substituted by Stripe, not rendered by you. On
`2026-03-25.dahlia`+ an `integration_identifier` label lets you compare flows in
the Dashboard (floor checked 2026-08-30 against Stripe's changelog).
Three decisions belong to `stripe-best-practices`: **usage-based billing**
(Metronome for anything new; Billing Meters is a low-level primitive), **tax**
(`automatic_tax` collects nothing until a registration exists — enabling it is
not compliance), and **Connect** when money routes to third parties.
---
## The webhook is the payment
```ts
export async function POST(request: Request) {
const body = await request.text(); // RAW — not parsed JSON
const signature = request.headers.get("stripe-signature");
if (!signature) return json({ error: "missing signature" }, 400);
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return json({ error: "invalid signature" }, 400); // no detail — it is an oracle
}
const claim = await claimEvent(event.id); // INSERT on a primary key
if (claim === "completed") return json({ received: true, duplicate: true });
if (claim === "in_flight") return json({ error: "in flight" }, 500); // retry later
try {
await handle(event);
} catch {
await releaseEventClaim(event.id); // let the retry back in
return json({ error: "handler error" }, 500);
}
return json({ received: true });
}
```
- **Raw body.** Re-serializing a parsed body changes bytes and the signature
fails. Disable auto-parsing for this route.
- **Exempt this path — and only this path — from CSRF and session auth**, by
exact match: a prefix match over `/api/billing` exempts checkout too, which is
where the money is.
- **Claim before working — a claim is a receipt, not completion.** `SELECT`
then `INSERT` is a race; two deliveries 40 ms apart both credit. The row
stays `processing` until the grant's transaction marks it `completed` —
states, expiry, takeover and release:
[`references/webhook-events.md`](references/webhook-events.md).
- **Answer honestly.** 200 handled or duplicate, 400 bad signature, 5xx try
again, 200 for types you do not handle. Never 200 on failure to stop retries —
that discards a payment quietly.
- **Order inside a handler:** fallible external calls first, then one
transaction — entitlement, dedup marker, completion, outbox rows — then side
effects drain from the outbox, each under its own consumer key.
Per-event detail: [`references/webhook-events.md`](references/webhook-events.md).
---
## The redirect proves a browser
Stripe redirects when the card clears; the webhook lands when it lands. In that
window the user is on your success page and your database knows nothing.
Ship a **verify endpoint** the success page calls: retrieve the session, check
`metadata.userId` equals the caller, check `status === "complete"` and
`payment_status !== "unpaid"`, then perform *exactly the writes the webhook
would* — and let the unique constraint arbitrate. Whoever loses catches the
duplicate-key error and reports success.
The ownership check is security-critical: without it, any authenticated user who
learns a `cs_…` id claims someone else's purchase. This is also the only way
local development works — but it is a safety net, never the primary path. A user
who closes the tab must still get what they paid for.
---
## Renewal: billing_reason decides
`invoice.paid` fires for several different things. Granting product on all of
them is the most expensive mistake in this document.
| `billing_reason` | What it is | Grant? |
|---|---|---|
| `subscription_create` | first invoice | **no** — checkout did it |
| `subscription_cycle` | the renewal | **yes** |
| `subscription_update` | mid-cycle proration | **no** |
| `manual`, `subscription_threshold` | an invoice you or a meter raised | explicitly |
A quantity change emits `subscription_update` immediately. If that path grants,
a user who adds and removes a seat four times has been given four months of
product for four proration invoices.
**Read the period from the subscription item** —
`sub.items.data[0].current_period_start` / `current_period_end`. Recent API
versions moved it; code reading the old top-level fields gets `undefined` and
stores an epoch date, with no error.
Guard the grant with a marker (`lastGrantedPeriodStart`, or an audit row keyed
by invoice id) checked **inside** the same transaction as the grant, so the
webhook and the reconciliation job cannot both grant one period.
---
## `billing_mode` — a one-way choice made at creation
Stripe creates every subscription in one of two billing modes. The choice is made
at `subscriptions.create` (or `subscription_data` on a Checkout session), **cannot
be reversed**, and Stripe recommends **flexible** for new subscriptions.
It is in the body because it changes arithmetic the next sections teach: under
flexible mode a credit proration is computed from the amount **originally
debited**, so one change can emit **several** credit prorations where classic
emitted one. Code that takes `[0]` of the proration lines breaks quietly in the
clawback path — the one place here where a wrong number is money. It also decides
which field records a scheduled cancellation. Choosing between the modes is a
product decision `stripe-best-practices` owns.
## Seats and proration
- `proration_behavior`: `always_invoice` bills now, `create_prorations` defers
to the next invoice, `none` adjusts nothing — the right choice for a *revert*.
The call, with its compensating revert, is in
[`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
- **`payment_behavior: "error_if_incomplete"` on upgrades.** Without it a declined card leaves the
subscription upgraded and unpaid while your database agrees with the upgrade.
Catch `StripeCardError` and answer 402.
- **Write ordering:** Stripe first, then your database. If the database write
fails, revert Stripe with `proration_behavior: "none"` and log a revert
failure loudly — that is the one state a human must fix. The reverse order
bills for seats Stripe never sold.
- Cap quantity server-side, floor it at 1. Removing the last seat is a
cancellation and goes through that path. Reducing below what is in use is a
business decision: answer 409 with what must be released, and let the user
choose which.
---
## Cancellation, and the offer that deflects it
- **At period end is the default** — the user keeps what they paid for and
`status` stays `active`. Under **flexible** `billing_mode` a portal
cancellation sets `cancel_at` and leaves `cancel_at_period_end` **false** —
derive "is cancelling" from both fields, never from the boolean alone.
- **Do the teardown in `customer.subscription.deleted`**, never beside the API
call, so one path serves your UI, the portal and dunning alike.
- On `invoice.payment_failed`, mark `past_due` and notify — do not cancel.
Stripe's dunning decides the retries and the terminal state.
- **A save offer's eligibility is yours.** Stripe cannot answer "was this
customer already discounted" — a `duration=once` discount leaves
`subscription.discounts` at finalization — so track eligibility yourself.
Both, with the code:
[`references/cancellation-and-retention.md`](references/cancellation-and-retention.md).
## Refunds arrive cumulative
`charge.amount_refunded` is **the total refunded so far**, not this refund. Two
partial refunds deliver `4000` then `9000`; read as an increment, that claws back
$130 against a $90 charge. Compute the increment against a stored total and write
it with a compare-and-swap, so a concurrent delivery loses rather than clawing
back twice — the code is in
[`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
A refund belongs either to a one-off payment (find it by `payment_intent`) or to
a subscription invoice (no purchase row — resolve `charge.invoice` → invoice →
subscription). Handle both, or subscription refunds silently leave the customer
holding the product.
## Reconciliation
Webhooks are best-effort and outages are not hypothetical. Run a job that lists
subscriptions with `status: "all"`, creates what is missing, and updates status,
period, quantity and price where they differ — sequentially, because each
iteration opens a transaction and may call an external API. Any grant it performs
reuses the webhook's idempotency marker, or a nightly job becomes a nightly gift.
The guard that matters: mark local rows canceled when Stripe has no such
subscription, **excluding rows that were never Stripe's**. Comped, manual and
other-provider plans carry synthetic ids, and cancelling them is a self-inflicted
outage. The code, and the "Sync now" button it doubles as, are in
[`references/subscription-lifecycle.md`](references/subscription-lifecycle.md).
Money is minor units: convert once, at the boundary, and compare in integer cents
— `Math.abs(20.83 - 20.84) > 0.01` is `true` in floating point.
---
## Depending on one provider
One provider for card payments is a single point of failure for revenue, and the
decision belongs to the business rather than to this skill. What the code owes it
is a seam: keep the charge behind an interface, keep the customer id yours, and
never let a Stripe object id be the only key to a paying account. The argument,
the migration shapes and the cost are in
[`references/provider-concentration.md`](references/provider-concentration.md).
## Local development and the test matrix
Both in `references/testing-and-local-dev.md` — `stripe listen --forward-to`, the
CLI trigger verbs, the test cards and decline codes, and the clock tricks for
renewal and proration. The one thing to know before you get there: the signing
secret `stripe listen` prints is **not** the dashboard's, and using the wrong one
fails verification in a way that reads like a key problem.
+
+ ## Degradation
+
+ - **Not Claude Code** (Cursor, Codex, the skills CLI, the API container): the
+ `PreToolUse` money gate this pack ships does not exist there — nothing refuses a
+ refund, a payout, a dispute close or a live `sk_live_…` key on your behalf. Every
+ rule below still holds; it is advice again rather than a precondition, so the
+ authorisation step becomes a human one. Say which you are on before acting on money.
+ - **Installed by copy rather than as a plugin**: same loss, same remedy — a plain
+ `~/.claude/skills/` copy carries the doctrine and not `hooks/money-gate.js`. The
+ README names the categories and how one is signed off; without the hook, that
+ sign-off is a sentence somebody has to actually say.
+ - **No Stripe CLI, no network, or test keys only**: webhook signature verification and
+ the local replay loop are the parts that need the binary. State which is missing
+ once, do the rest, and record what could not be verified rather than implying it was.
## Before you ship
Not a second checklist — every rule below already has a section above, and a rule
with two homes drifts at one of them. These are the four whose failure is money
rather than an error page:
1. **The webhook is the payment.** Never grant on the redirect (§ *The webhook is
the payment*, § *The redirect proves a browser*).
2. **Verify the signature against the raw body.** A parsed body fails verification
for a reason that reads like a key problem.
3. **Every handler is idempotent on `event.id`.** Stripe retries, and a retry that
grants twice is a refund conversation.
4. **Reconcile on a schedule.** A webhook that never arrived leaves a paid customer
without access, and nothing in the logs says so (§ *Reconciliation*).
The full security checklist lives in `references/stripe-agent-toolchain.md`; every
pitfall it used to list is stated where the rule is, which is the only place it can
be kept true.