> ## Documentation Index
> Fetch the complete documentation index at: https://docs.prodbreak.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stripe

> Reference for the Stripe pack — objects, events, errors, and formats, faithful to the live Stripe API — plus the thin prod-break control layer.

<Info>
  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](#control-prod-break) at
  the bottom.
</Info>

```bash theme={null}
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
```

```ts theme={null}
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)
```

<Tabs>
  <Tab title="payment_intent">
    ```jsonc theme={null}
    {
      "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.
  </Tab>

  <Tab title="charge">
    ```jsonc theme={null}
    {
      "id": "ch_3TgZPRFt5AvKEhaS1xtOMQb3", "object": "charge",
      "amount": 2500, "amount_captured": 2500, "amount_refunded": 1000,
      "status": "succeeded",        // pending | succeeded | failed   (terminal — refunds flip flags, not status)
      "paid": true, "captured": true, "refunded": false, "disputed": false,
      "payment_intent": "pi_3TgZ…", "balance_transaction": "txn_…",
      "payment_method_details": { "card": { "brand": "visa", "last4": "4242", "fingerprint": "…" } },
      "outcome": { "network_status": "approved_by_network", "type": "authorized",
                   "risk_level": "normal", "risk_score": 13, "seller_message": "Payment complete." },
      "failure_code": null, "failure_message": null, "receipt_url": "https://pay.stripe.com/receipts/…"
    }
    ```
  </Tab>

  <Tab title="subscription">
    ```jsonc theme={null}
    {
      "id": "sub_1TgZPyFt5AvKEhaSr9tILucM", "object": "subscription",
      "status": "active",           // incomplete|incomplete_expired|trialing|active|past_due|canceled|unpaid|paused
      "customer": "cus_UfvECXAXeiSPI5",
      "items": { "object": "list",  // nested list envelope, has_more inside the object
                 "data": [ { "id": "si_UfvG10sOLUJsH2", "object": "subscription_item",
                             "price": { "id": "price_1TgZPP…", "recurring": { "interval": "month" } },
                             "quantity": 1 } ], "has_more": false },
      "latest_invoice": "in_1TgZPyFt5AvKEhaSlCu6DL7f",   // already paid on the sync create arm
      "billing_cycle_anchor": 1781049050,
      "current_period_start": 1781049050, "current_period_end": 1783641050,
      "collection_method": "charge_automatically", "cancel_at_period_end": false,
      "cancellation_details": { "reason": null, "comment": null, "feedback": null }
    }
    ```
  </Tab>

  <Tab title="invoice">
    ```jsonc theme={null}
    {
      "id": "in_1TgZQbFt5AvKEhaSfah8kBr6", "object": "invoice",
      "status": "paid",             // draft → open → paid | void | uncollectible   (zero-total: draft → paid, skips open)
      "customer": "cus_UfvECXAXeiSPI5", "subscription": null,
      "billing_reason": "manual",   // manual | subscription_create | subscription_cycle | …
      "number": "EJVDDA25-0003",    // <customer.invoice_prefix>-NNNN, minted at finalize
      "total": 2500, "amount_due": 2500, "amount_paid": 2500, "amount_remaining": 0,
      "collection_method": "send_invoice", "due_date": 1783641131,
      "hosted_invoice_url": "https://invoice.stripe.com/i/…", "invoice_pdf": "https://…/pdf",
      "attempt_count": 0, "auto_advance": false,
      "next_payment_attempt": null, // the dunning timer when auto-collection fails
      "status_transitions": { "finalized_at": 1781049089, "paid_at": 1781049094, "voided_at": null }
    }
    ```
  </Tab>

  <Tab title="customer">
    ```jsonc theme={null}
    {
      "id": "cus_UfvECXAXeiSPI5", "object": "customer",
      "email": "jane.tester@example.com", "name": "Jane Tester",
      "balance": 0, "currency": null,     // set on first invoice
      "delinquent": false,
      "invoice_prefix": "EJVDDA25", "next_invoice_sequence": 1,
      "invoice_settings": { "default_payment_method": "pm_1TgZPQ…" },
      "test_clock": null,                 // set ⇒ hidden from the plain list (filter ?test_clock=)
      "created": 1781048932, "livemode": false, "metadata": {}
    }
    ```

    `DELETE` returns a three-field tombstone: `{ "id", "object": "customer", "deleted": true }`.
  </Tab>

  <Tab title="event">
    ```jsonc theme={null}
    {
      "id": "evt_1TgZRBFt5AvKEhaSzODwoPJU", "object": "event",
      "type": "invoice.paid",             // resource.action
      "api_version": "2024-12-18.acacia", // the ACCOUNT's pinned version, always
      "created": 1781049143, "livemode": false, "pending_webhooks": 0,
      "request": { "id": "req_…|null", "idempotency_key": "…|null" },
      "data": { "object": { /* full post-transition snapshot; refs stay ids — expand never applies */ },
                "previous_attributes": { /* only on *.updated: the old values of changed fields */ } }
    }
    ```
  </Tab>
</Tabs>

## 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`              |

<Note>
  * **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](/guides/test-a-webhook)).
</Note>

## 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.**

```jsonc theme={null}
// 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` / `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                                              |

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:

```jsonc theme={null}
// 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](#control-prod-break): 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`                         |

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 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](/guides/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](/concepts/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.

| 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_3TgZPR`**`Ft5AvKEhaS`**`15Ju0xHY` ← `acct_1QfWzC`**`Ft5AvKEhaS`**); 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.

```ts theme={null}
// 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
```

<Note>
  * **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](/concepts/exogenous) · [force a branch](/concepts/branching) ·
    [world events](/concepts/causes-not-effects) · [the clock](/concepts/clock) · [HTTP API](/reference/wire).
</Note>

Pinned against **Stripe API 2024-12-18.acacia** (test-mode capture 2026-06-09), guarded by
[contract tests](/fidelity) that diff the pack against captured live responses.
