Faithful to the live Stripe API (test-mode capture 2026-06-09, account version pinned
2024-12-18.acacia). The object shapes, event names, error envelopes, IDs and formats below are
exactly what real Stripe returns — this page is the vocabulary to assert against, not a tutorial
on Stripe. If you know the API, the only new surface is the control layer at
the bottom.
Objects
- payment_intent
- charge
- subscription
- invoice
- customer
- event
requires_payment_method with last_payment_error set, and stays retryable.Events
resource.action. data.object is a full snapshot sharing the REST serializer. Three origins
drive them: your own API calls, the clock (renewals, auto-finalize, dunning), and the world
(the issuing bank declines, demands 3DS, the cardholder disputes). 30 types captured live:
| Resource | Event types (captured ✅) |
|---|---|
payment_intent | created · succeeded · payment_failed · requires_action |
charge | succeeded · failed · updated · refunded · refund.updated |
refund | created · updated |
customer | created · updated · subscription.created · subscription.updated |
invoice | created · finalized · paid · payment_succeeded · updated · upcoming |
invoice_payment | paid |
invoiceitem | created |
product / price / plan | product.created · price.created · plan.created |
payment_method | attached |
test_helpers | test_clock.created · test_clock.advancing · test_clock.ready |
- Era layering is real: a recurring price dual-writes a legacy
plan.created; paying an invoice emitsinvoice.paidand deprecated-but-aliveinvoice.payment_succeededand new-generationinvoice_payment.paid. Handlers in the wild key on any of the three. event.request.idis not a reliable did-I-cause-this marker — internally generated children of your own call (the subscription-create invoice) carryrequest: { id: null }.invoice.upcominghas no persisted object (data.object.idisnull) — it fires before the renewal invoice exists.- Delivery:
Stripe-Signature: t=<ts>,v1=<hmac>— HMAC-SHA256 over"{t}.{raw_body}"keyed by the endpoint’swhsec_. Stripe returns that secret only on create; in the sandbox it’s stable and re-readable (test a webhook).
Errors
One envelope, but two surfaces — anderror.code is per-class optional (whole families ship
message-only: both 401s, bad Stripe-Version, “Invoice is already paid”).
1 · A call fails → HTTP error response.
type / code | HTTP | Trigger |
|---|---|---|
invalid_request_error / — | 401 | missing key, or invalid key (echoed masked: rk_test_*********y123) |
invalid_request_error / parameter_missing · parameter_unknown | 400 | bad params (param set) |
invalid_request_error / — | 400 | invalid enum (message lists valid values) · FSM violation · bad version |
idempotency_error / — | 400 | same Idempotency-Key, different params |
invalid_request_error / resource_missing | 404 | bogus id (param: "id") |
invalid_request_error / — | 404 | unknown route — JSON envelope, “Unrecognized request URL” |
card_error / card_declined | 402 | the decline — see surface 2 |
limit=99999 → 200).
2 · A payment fails → the error is also durable state. The 402 body carries the issuer’s
verdict and the full PaymentIntent, post-bounce:
failed charge with failure_code + outcome, the PI’s
last_payment_error, plus payment_intent.payment_failed and charge.failed events. Async
failures (ACH returns, disputes) skip the synchronous envelope entirely — state + events are the
only surface.
Magic values
Stripe is the one API whose documented test contract is magic inputs — so the pack honors them. Each documented test card is an alias for the matching world trigger: your existing suite keeps passing after the repoint, unmodified.| Value | Outcome |
|---|---|
pm_card_visa / tok_visa | confirm → succeeded (sync) |
pm_card_chargeDeclined | HTTP 402 card_declined / generic_decline, PI bounces |
pm_card_chargeDeclinedInsufficientFunds | as above, decline_code: insufficient_funds |
pm_card_threeDSecure2Required | requires_action + next_action.use_stripe_sdk |
pm_card_visa_chargeDeclinedExpiredCard · …IncorrectCvc | expired_card · incorrect_cvc |
unrecognized Stripe test value) instead of silently succeeding —
a repointed suite never passes for the wrong reason.
For outcomes the real test mode can’t produce on demand (a decline at renewal, a dispute on a
seeded charge, a webhook retry storm), use the control surface — that’s the point of the sandbox.
Test clocks
/v1/test_helpers/test_clocks is fully supported — create, attach customers (hidden from the
plain customer list, exactly like real Stripe), advance, delete. Two deliberate upgrades over the
real thing:
| Real Stripe sandbox | Here | |
|---|---|---|
| Advance | async — advancing, poll ~45 s per cycle | synchronous — due events drain in order before the call returns; the first poll already reads ready |
| Caps | 3 customers / 3 subscriptions per clock, advance ≤ 2 intervals | none |
| Backward time | impossible | seed history instead (seed history) |
advancing
status — if yours asserts it, it’s testing Stripe’s infrastructure, not your code.
Pinnable values
The fields that originate outside Stripe’s logic — the only ones you supply. Everything else (ids,status, totals, timestamps, number) is engine-derived and unfakeable.
| Path | What | Binds at | null when |
|---|---|---|---|
last_payment_error.decline_code | the issuer’s reason | charge attempt fails | payment succeeds |
charge.outcome.* | network/risk verdict (risk_score, seller_message, …) | charge resolves | never attempted |
payment_intent.next_action | the 3DS/redirect payload | world demands auth | other statuses |
payment_method.card.* | what card the customer holds (brand, last4, fingerprint) | PM created | — |
balance.{available,pending} | opening balances (movement is engine math) | seed | — |
Formats & conventions
| Requests | form-encoded (items[0][price]=…, expand[]=customer) — responses JSON |
| Auth | HTTP Basic, key as username (-u "sk_test_…:") or Bearer (sandbox: the printed pbw_… key) |
| IDs | numbered ids embed the account fingerprint (pi_3TgZPRFt5AvKEhaS15Ju0xHY ← acct_1QfWzCFt5AvKEhaS); newer ids are <prefix>_<14 base62> (cus_UfvECXAXeiSPI5) |
| Pagination | { object: "list", data[], has_more, url }; cursor = last id via starting_after; limit 1–100 |
| Expansion | expand[]=latest_charge.balance_transaction inflates id → object, 4 levels deep — shape is per-request |
| Idempotency | Idempotency-Key on POST; replay → identical body + idempotent-replayed: true header; changed params → idempotency_error |
| Versioning | account-pinned (2024-12-18.acacia here), overridable per request via Stripe-Version; events always use the account pin |
| Amounts | integer minor units (2500 = CA$25.00) + lowercase ISO currency |
Control (prod-break)
The only surface that isn’t real Stripe. Everything above is the vendor’s; this is how you make a given outcome happen on demand.- Event names and behavior are pack-declared — the engine is generic; the Stripe vocabulary
above lives in this pack.
sandbox.world.next()lists the world events whose preconditions hold. - Control calls hit
/__admin__/*. Their errors go to your test, not the app under test, and are not Stripe’s error envelope. - Magic test inputs are honored here (unlike other packs) because they’re Stripe’s documented
contract — each desugars to the same apply-fn as its
world.triggerequivalent. They only cover call-time outcomes; renewal-time and webhook-time outcomes need the control surface. - Concepts: exogenous values · force a branch · world events · the clock · HTTP API.