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.
npx prod-break run stripe
# url=http://localhost:8801   key=pbw_3f9a…   pack=stripe@0.1.0
# → every official SDK takes a base-URL override; nothing else changes
const stripe = new Stripe(key, { host: "localhost", port: 8801, protocol: "http" }); // node
# stripe.api_base = "http://localhost:8801"                                          # python

Objects

product ──< price

customer ──< payment_method (attach)
   │  ╲
   │   ╲──< subscription ──< subscription_item ─▶ price
   │              │ (renews on the clock)
   │              ▼
   │          invoice ──< line_item
   │              │ (collection)
   ▼              ▼
payment_intent ──< charge ──< refund
event (log of all of it)        webhook_endpoint (signed delivery)
{
  "id": "pi_3TgZPRFt5AvKEhaS15Ju0xHY", "object": "payment_intent",
  "amount": 2500, "currency": "cad",
  "status": "succeeded",        // requires_payment_method|requires_confirmation|requires_action|processing|requires_capture|succeeded|canceled
  "customer": "cus_UfvECXAXeiSPI5",
  "payment_method": "pm_1TgZPQFt5AvKEhaShrJSAe64",
  "latest_charge": "ch_3TgZPRFt5AvKEhaS1xtOMQb3",   // expandable
  "client_secret": "pi_3TgZ…_secret_…",
  "next_action": null,          // 3DS arm: { "type": "use_stripe_sdk", … }
  "last_payment_error": null,   // decline arm: the card_error that bounced it
  "amount_received": 2500, "capture_method": "automatic_async",
  "created": 1781048955, "livemode": false, "metadata": {}
}
A decline does not park the PI in a failed state — it bounces back to 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:
ResourceEvent types (captured ✅)
payment_intentcreated · succeeded · payment_failed · requires_action
chargesucceeded · failed · updated · refunded · refund.updated
refundcreated · updated
customercreated · updated · subscription.created · subscription.updated
invoicecreated · finalized · paid · payment_succeeded · updated · upcoming
invoice_paymentpaid
invoiceitemcreated
product / price / planproduct.created · price.created · plan.created
payment_methodattached
test_helperstest_clock.created · test_clock.advancing · test_clock.ready
  • Era layering is real: a recurring price dual-writes a legacy plan.created; paying an invoice emits invoice.paid and deprecated-but-alive invoice.payment_succeeded and new-generation invoice_payment.paid. Handlers in the wild key on any of the three.
  • event.request.id is not a reliable did-I-cause-this marker — internally generated children of your own call (the subscription-create invoice) carry request: { id: null }.
  • invoice.upcoming has no persisted object (data.object.id is null) — it fires before the renewal invoice exists.
  • Delivery: Stripe-Signature: t=<ts>,v1=<hmac> — HMAC-SHA256 over "{t}.{raw_body}" keyed by the endpoint’s whsec_. 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 — and error.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.
// POST /v1/payment_intents with no amount → HTTP 400
{ "error": { "type": "invalid_request_error", "code": "parameter_missing", "param": "amount",
             "message": "Missing required param: amount.",
             "doc_url": "https://stripe.com/docs/error-codes/parameter-missing",
             "request_log_url": "https://dashboard.stripe.com/acct_…/test/workbench/logs?…" } }
type / codeHTTPTrigger
invalid_request_error / —401missing key, or invalid key (echoed masked: rk_test_*********y123)
invalid_request_error / parameter_missing · parameter_unknown400bad params (param set)
invalid_request_error / —400invalid enum (message lists valid values) · FSM violation · bad version
idempotency_error / —400same Idempotency-Key, different params
invalid_request_error / resource_missing404bogus id (param: "id")
invalid_request_error / —404unknown route — JSON envelope, “Unrecognized request URL”
card_error / card_declined402the decline — see surface 2
Strictness boundary: names and enums are strict 400s; numeric bounds are silently clamped (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:
// confirm with a declining card → HTTP 402
{ "error": { "type": "card_error", "code": "card_declined", "decline_code": "generic_decline",
             "message": "Your card was declined.",
             "charge": "ch_3TgZPW…",                      // the failed charge — retrievable forever
             "payment_method": "pm_1TgZPV…",
             "payment_intent": { "id": "pi_3TgZPW…", "status": "requires_payment_method",
                                 "last_payment_error": { /* this same error */ } } } }
…and the traces persist: a 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.
ValueOutcome
pm_card_visa / tok_visaconfirm → succeeded (sync)
pm_card_chargeDeclinedHTTP 402 card_declined / generic_decline, PI bounces
pm_card_chargeDeclinedInsufficientFundsas above, decline_code: insufficient_funds
pm_card_threeDSecure2Requiredrequires_action + next_action.use_stripe_sdk
pm_card_visa_chargeDeclinedExpiredCard · …IncorrectCvcexpired_card · incorrect_cvc
Each recognized value desugars to the same apply-fn as the matching control-surface call — same state, same events, no separate code path. A documented Stripe test value the pack doesn’t recognize yet fails loudly (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 sandboxHere
Advanceasync — advancing, poll ~45 s per cyclesynchronous — due events drain in order before the call returns; the first poll already reads ready
Caps3 customers / 3 subscriptions per clock, advance ≤ 2 intervalsnone
Backward timeimpossibleseed history instead (seed history)
A Stripe test clock here is a named handle on the world’s single clock — one timeline per world. Need two independent timelines? Run two worlds (worlds); that’s the CI model anyway. The only observable divergence: a suite can never catch the 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.
PathWhatBinds atnull when
last_payment_error.decline_codethe issuer’s reasoncharge attempt failspayment succeeds
charge.outcome.*network/risk verdict (risk_score, seller_message, …)charge resolvesnever attempted
payment_intent.next_actionthe 3DS/redirect payloadworld demands authother 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

Requestsform-encoded (items[0][price]=…, expand[]=customer) — responses JSON
AuthHTTP Basic, key as username (-u "sk_test_…:") or Bearer (sandbox: the printed pbw_… key)
IDsnumbered ids embed the account fingerprint (pi_3TgZPRFt5AvKEhaS15Ju0xHYacct_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
Expansionexpand[]=latest_charge.balance_transaction inflates id → object, 4 levels deep — shape is per-request
IdempotencyIdempotency-Key on POST; replay → identical body + idempotent-replayed: true header; changed params → idempotency_error
Versioningaccount-pinned (2024-12-18.acacia here), overridable per request via Stripe-Version; events always use the account pin
Amountsinteger 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.
// SETUP — seed your create-once definitions; everything references them by id
await sandbox.seed("prices", [{ id: "price_pro_monthly", unit_amount: 1500, currency: "cad",
  recurring: { interval: "month" }, product: "prod_pro" }]);
await sandbox.seed("customers", [{ id: "cus_jane", email: "jane@example.com",
  invoice_settings: { default_payment_method: "pm_jane_visa" } }]);

// pin an out-of-control value, or force the decline branch
await sandbox.exogenous.on("charge", "outcome.risk_score", 92);
await sandbox.world.trigger("payment.declined", { payment_intent_id: "pi_…",
  decline_code: "insufficient_funds" });   // 402 + bounced PI + payment_failed/charge.failed events fall out

// the renewal nobody can test on real Stripe: decline at the cycle, then watch dunning
await sandbox.world.trigger("payment.declined", { subscription_id: "sub_…", at: "next_renewal" });
await sandbox.clock.advance("32d");        // invoice.upcoming → created(subscription_cycle) → payment_failed → past_due

// arm an inbound API fault — your call gets Stripe's real envelope above
await sandbox.faults.arm("payment_intents", "create", { status: 429, type: "rate_limit_error" });

// webhooks: stable signing secret, replayable events, live-fidelity retry curve
const { secret } = await sandbox.webhooks.endpoint("we_…");   // same whsec_ across restarts
  • 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.trigger equivalent. 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.
Pinned against Stripe API 2024-12-18.acacia (test-mode capture 2026-06-09), guarded by contract tests that diff the pack against captured live responses.