Scope: what the Stripe twin implements — and deliberately doesn't
Worlds' rule is depth over breadth, one connector at a time (Stripe is the first twin, and the one this page covers): every behavior on the implemented surface has lifecycle-correct state and a test that fails if it drifts. Anything not listed as implemented returns Stripe's Unrecognized request URL 404 rather than a half-working imitation.
Implemented (/v1/*)
| resource | operations | depth notes |
|---|---|---|
| Customers | create, retrieve, update, delete, list (email filter) |
deleted customers stay retrievable as {deleted: true} stubs (HTTP 200, like real Stripe); deletes hide them from lists and block updates; deleting a customer does not cancel its subscriptions (Known gaps) |
| Products | create, retrieve, list (active) |
minimal fields, enough for prices/invoices |
| Prices | create, retrieve, list (product, active) |
one-time and recurring; recurring backs subscriptions; unit_amount ≥ 0 |
| Invoice items | create, retrieve, list (customer, pending) |
pending items sweep into the next invoice; quantity ≥ 0 |
| Invoices | create, retrieve, delete (drafts — the items it swept are stranded, Known gaps), list (customer, status, subscription), lines, finalize, pay, void, mark_uncollectible |
full draft → open → paid/void/uncollectible lifecycle with status-transition timestamps, deterministic numbers, guards with Stripe error codes |
| PaymentIntents | create (confirm supported), retrieve, list (customer), confirm, cancel |
test tokens pm_card_visa and pm_card_chargeDeclined; declines record the failed charge + last_payment_error |
| Charges | retrieve, list (customer, payment_intent, invoice) |
born only from flows (invoice pay, PI confirm, subscription billing); amount_refunded/refunded maintained |
| Refunds | create (charge or payment_intent), retrieve, list (charge) |
partial refunds accumulate; over-refund rejected with Stripe's message; full re-refund → charge_already_refunded; a seeded amount_refunded lists one refund for that total, its id derived from the seed hash and the charge id |
| Subscriptions | create, retrieve, update (cancel_at_period_end), cancel (DELETE), list (customer, status, price) |
creation bills the first period; clock advance bills every elapsed period at its boundary |
| Events | retrieve, list (type) |
every mutation emits the correct evt_; pending_webhooks reflects the delivery queue |
Plus, everywhere: Stripe's exact error envelope (type/code/message/param, correct statuses), cursor pagination (limit, starting_after, ending_before, has_more), idempotency keys (replay + idempotency_error; a request rejected by parameter validation reserves no key, an executed outcome — errors and 500s included — is replayed), Bearer/Basic auth with Stripe-shaped 401s, form-encoded bracket syntax bodies (the real SDKs' wire format), and the Stripe-Should-Retry header on injected errors where Stripe sends it (lock_timeout), which is what the official SDKs' retry logic reads. Official stripe-node 16 (the demo agent) and 22 (the cold agent, stock defaults: two retries, an idempotency key on every POST, API version 2026-08-26.dahlia) run against the twin in the test suite; the twin ignores Stripe-Version and answers in its own pinned shapes.
Deliberately not implemented
Where one of these is reachable by a parameter on an implemented route, the request is refused with worlds_unsupported_parameter (below, "Refused parameters") — never accepted and silently ignored.
Payment plumbing — PaymentMethods as objects (only the two test tokens), SetupIntents, 3DS/requires_action, captures/holds (capture_method: manual), multi-currency conversion, balance/payouts/transfers, Connect, disputes.
Billing breadth — coupons/discounts/promotion codes, tax (rates, automatic tax), credit notes, quotes, usage-based/metered billing, tiered prices, trials, proration, subscription schedules, multi-item subscriptions (one item per subscription), collection_method: send_invoice (auto-charge only), invoice auto_advance timers (finalize/pay are explicit or subscription-driven).
Platform surface — Checkout, Payment Links, Billing Portal, Sources/Tokens (legacy), files, search endpoints (use list filters or the admin state API), expand[] (objects embed what the twin considers primary; expansion params are accepted and ignored).
Field-level omissions on implemented objects (returned as null/absent rather than half-implemented): address/shipping on customers, default_payment_method, application_fee_amount, transfer_data, on_behalf_of, statement_descriptor overrides, receipt_url contents, balance_transaction objects, invoice PDFs/hosted_invoice_url, webhook endpoint objects via /v1/webhook_endpoints (the twin's endpoint is configured via the admin API instead — one per world).
Semantics simplifications — livemode is always false; the account is single-currency, USD (ACCOUNT_DEFAULT_CURRENCY), so where Stripe reads the account's default currency — pinning a customer's currency the moment a non-zero balance denominates it — the twin answers usd; one currency per invoice/subscription (whatever its lines/price use) and one per customer balance (the first balance transaction pins currency, as at Stripe; a transaction in another currency is refused with worlds_unsupported_parameter rather than summed into balance, and the balance settles only invoices in its own currency — a usd credit leaves a eur invoice charged in full, as Stripe's per-currency balances do (drift: credit-other-currency-not-applied); a negative-total invoice in a second currency, whose credit the twin cannot bank beside the pinned balance, is refused with the same code — and so are the requests that would build one, a plan change with prorations and a negative invoice item, each in a second currency on a pinned customer or beside pending credit lines in another currency, and the balance is not pinned — by a transaction, a balance update or a negative total flowing back — while a credit line in another currency is pending, and POST /v1/prices refuses a negative unit_amount as Stripe does (the one other way to a negative renewal line), so no request builds a renewal the twin cannot bill; a renewal that meets the refusal anyway (state written outside the API) is atomic — the clock movement rolls back, a twin request answers a logged api_error 500 and clock/advance a 409 — and in clock_mode: "auto" every twin request then answers that 500 until the world is reset, while in manual mode a positive line in the invoice's currency or cancel_at_period_end=true on the subscription frees the next advance; Stripe's own model, a per-currency invoice_credit_balance, is a follow-up plan); one currency per one-off invoice too: POST /v1/invoices collects the pending items of the currency named by currency, or of the only currency pending, and 400s on currency when more than one is pending and none is named, while a renewal sweeps only the pending items in its subscription's currency; charges cannot be created directly (POST /v1/charges is legacy); no rate limiting unless a scenario injects it.
One rule for credit in two currencies — a customer's pending credit lines are in one currency, the balance's once it is pinned; the twin refuses, at the request, whatever would break that (a second-currency credit line, a pin beside pending credits, a negative price), so a renewal never meets a credit it cannot bank. Stripe's per-currency invoice_credit_balance is the faithful model and a follow-up plan.
If your agent needs something in this list, open an issue — the bar for adding surface is "we will model its state correctly and test its lifecycle", not "we can return a plausible JSON blob".
Refused parameters
Real Stripe accepts every parameter below and changes how money moves; the twin does not model it. Rather than return a normal success for a request whose real outcome would differ — a manual capture that never captures, a coupon that never discounts, a trial that never defers — the twin refuses the request before the handler runs: 400 invalid_request_error with the twin's own code worlds_unsupported_parameter (not a Stripe code, so a test can tell "the twin cannot grade this" from "Stripe rejected this"), the parameter in param, no idempotency result (a corrected retry under the same key runs; a key already holding a stored result keeps replaying it, and a request under that key that carries a refused parameter is refused rather than answered idempotency_error), and a row in the request log whose unsupported column names the parameter — GET /admin/worlds/:id/requests?unmatched=true lists these beside the un-twinned routes, one row per route and parameter. The request has been authenticated and counted by then: it uses one of the key's requests_used and advances the scenario counters it matched, exactly as an injected or unmatched request does; what does not happen is the handler, the auto-clock tick and the idempotency store. The drift battery pins the gap: pi-capture-manual-refused and price-trial-period-days expect real Stripe to accept what the twin refuses.
Values marked allowed are the twin's own behavior and pass through; any other value is refused, including one Stripe would reject too — guessing which unknown values are harmless is how a new Stripe value would slip through. A boolean whose false is Stripe's do-nothing default passes as false. An absent, null or empty value is not "present": coupon="" clears a discount, and there is none to clear. Additive fields Stripe merely echoes (statement_descriptor, receipt_email, expand[], setup_future_usage, automatic_payment_methods) are accepted and ignored as before; values a handler already rejects with its own Stripe-shaped 400 (a second subscription item, an unknown proration_behavior) are not listed twice. The table is a denylist of known spellings, so its limit is inherent: it catches the money-changing parameters it names and nothing else — an unknown parameter, a case variant such as Capture_Method included, is ignored wholesale, as every unknown parameter always has been; and a listed parameter sent in a shape the table does not read passes too, so automatic_tax=false as a flat string (Stripe requires the automatic_tax[enabled] hash and 400s the string) is accepted here, a pre-existing leniency. The same code also marks the state-dependent refusals a handler makes itself — a balance transaction in a second currency on a customer whose balance is already denominated, a plan change whose prorations or an invoice item whose credit would land in a second currency on such a customer or beside pending credit lines in another currency, a balance pinned — by a transaction, a balance update or a negative total flowing back — while such a line is pending, and a negative-total invoice in a second currency finalized on one (above, under semantics simplifications) — and those, unlike the table's, are execution-phase errors: the twin had to read the customer to know, so a keyed request's outcome is stored under its idempotency key exactly as Stripe stores its own answer (the message says so when there is a key; an unkeyed request stores nothing and the message says nothing of it), and a corrected retry under the same key gets idempotency_error, not a second attempt. Their param is the parameter the request sent (currency, price, balance, proration_behavior), and absent on a finalize, which sends none.
The table is REFUSED_PARAMS in packages/server/src/twin/unsupported.ts; a test fails when this page and that table disagree in either direction.
| route | parameter | allowed | why |
|---|---|---|---|
POST /v1/payment_intents |
capture_method |
automatic, automatic_async |
a manual capture holds funds without moving them; the twin captures on confirm |
POST /v1/payment_intents |
payment_method_options[card][capture_method] |
— | the card-level capture_method overrides the top-level one: manual holds the funds instead of capturing them |
POST /v1/payment_intents |
application_fee_amount |
— | a Connect fee splits the charge |
POST /v1/payment_intents |
transfer_data |
— | a Connect transfer moves money to another account |
POST /v1/payment_intents |
on_behalf_of |
— | Connect settlement on another account |
POST /v1/payment_intents/:id/confirm |
capture_method |
automatic, automatic_async |
a manual capture holds funds without moving them; the twin captures on confirm |
POST /v1/payment_intents/:id/confirm |
payment_method_options[card][capture_method] |
— | the card-level capture_method overrides the top-level one: manual holds the funds instead of capturing them |
POST /v1/subscriptions |
trial_period_days |
— | a trial defers the first charge |
POST /v1/subscriptions |
trial_end |
— | a trial defers the first charge |
POST /v1/subscriptions |
trial_from_plan |
false |
takes the trial from the price's recurring[trial_period_days]; a trial defers the first charge |
POST /v1/subscriptions |
coupon |
— | a discount changes every invoice total |
POST /v1/subscriptions |
discounts |
— | a discount changes every invoice total |
POST /v1/subscriptions |
promotion_code |
— | a discount changes every invoice total |
POST /v1/subscriptions |
default_tax_rates |
— | tax changes invoice totals |
POST /v1/subscriptions |
automatic_tax[enabled] |
false |
tax changes invoice totals |
POST /v1/subscriptions |
billing_cycle_anchor |
— | moves the first period boundary and prorates the first invoice |
POST /v1/subscriptions |
billing_cycle_anchor_config |
— | moves the first period boundary and prorates the first invoice |
POST /v1/subscriptions |
backdate_start_date |
— | bills past periods at creation |
POST /v1/subscriptions |
collection_method |
charge_automatically |
send_invoice leaves the invoice open instead of charging it |
POST /v1/subscriptions |
days_until_due |
— | only meaningful with send_invoice |
POST /v1/subscriptions |
payment_behavior |
allow_incomplete, error_if_incomplete |
default_incomplete and pending_if_incomplete leave the invoice unpaid; the twin charges at once |
POST /v1/subscriptions |
cancel_at |
— | the twin models cancel_at_period_end only |
POST /v1/subscriptions |
add_invoice_items |
— | one-time lines change the first invoice total |
POST /v1/subscriptions |
items[0][price_data] |
— | an inline price the twin cannot read; create the price, then pass items[0][price] |
POST /v1/subscriptions |
items[0][tax_rates] |
— | tax changes the line total |
POST /v1/subscriptions |
items[0][discounts] |
— | a discount changes the line total |
POST /v1/subscriptions |
application_fee_percent |
— | a Connect fee splits every invoice's charge |
POST /v1/subscriptions |
transfer_data |
— | a Connect transfer moves every invoice's charge to another account |
POST /v1/subscriptions |
on_behalf_of |
— | Connect settlement on another account |
POST /v1/subscriptions |
billing_thresholds |
— | bills when the amount crosses a threshold, not at the period boundary |
POST /v1/subscriptions/:id |
trial_end |
— | a trial defers the next charge |
POST /v1/subscriptions/:id |
coupon |
— | a discount changes every invoice total |
POST /v1/subscriptions/:id |
discounts |
— | a discount changes every invoice total |
POST /v1/subscriptions/:id |
promotion_code |
— | a discount changes every invoice total |
POST /v1/subscriptions/:id |
default_tax_rates |
— | tax changes invoice totals |
POST /v1/subscriptions/:id |
automatic_tax[enabled] |
false |
tax changes invoice totals |
POST /v1/subscriptions/:id |
billing_cycle_anchor |
unchanged |
resetting the anchor prorates and moves every renewal |
POST /v1/subscriptions/:id |
proration_date |
— | changes the proration amounts |
POST /v1/subscriptions/:id |
cancel_at |
— | the twin models cancel_at_period_end only |
POST /v1/subscriptions/:id |
pause_collection |
— | pauses charging while the subscription stays active |
POST /v1/subscriptions/:id |
collection_method |
charge_automatically |
send_invoice leaves the invoice open instead of charging it |
POST /v1/subscriptions/:id |
days_until_due |
— | only meaningful with send_invoice |
POST /v1/subscriptions/:id |
payment_behavior |
allow_incomplete, error_if_incomplete |
default_incomplete and pending_if_incomplete leave the invoice unpaid; the twin charges at once |
POST /v1/subscriptions/:id |
add_invoice_items |
— | one-time lines change the next invoice total |
POST /v1/subscriptions/:id |
items[0][price_data] |
— | an inline price the twin cannot read; create the price, then pass items[0][price] |
POST /v1/subscriptions/:id |
items[0][tax_rates] |
— | tax changes the line total |
POST /v1/subscriptions/:id |
items[0][discounts] |
— | a discount changes the line total |
POST /v1/subscriptions/:id |
items[0][deleted] |
false |
deletes the one item — Stripe 400s unless another item replaces it, and the twin models exactly one |
POST /v1/subscriptions/:id |
application_fee_percent |
— | a Connect fee splits every invoice's charge |
POST /v1/subscriptions/:id |
transfer_data |
— | a Connect transfer moves every invoice's charge to another account |
POST /v1/subscriptions/:id |
on_behalf_of |
— | Connect settlement on another account |
POST /v1/subscriptions/:id |
billing_thresholds |
— | bills when the amount crosses a threshold, not at the period boundary |
DELETE /v1/subscriptions/:id |
invoice_now |
false |
invoices pending prorations at once |
DELETE /v1/subscriptions/:id |
prorate |
false |
credits the unused time on cancel |
POST /v1/invoices |
collection_method |
charge_automatically |
send_invoice leaves the invoice open instead of charging it |
POST /v1/invoices |
days_until_due |
— | only meaningful with send_invoice |
POST /v1/invoices |
due_date |
— | only meaningful with send_invoice |
POST /v1/invoices |
auto_advance |
false |
Stripe finalizes and collects on its own about an hour later; the twin finalizes and pays only when told to |
POST /v1/invoices |
discounts |
— | a discount changes the invoice total |
POST /v1/invoices |
default_tax_rates |
— | tax changes invoice totals |
POST /v1/invoices |
automatic_tax[enabled] |
false |
tax changes invoice totals |
POST /v1/invoices |
pending_invoice_items_behavior |
include |
exclude leaves the pending items off the invoice; the twin always sweeps them |
POST /v1/invoices |
subscription |
— | includes only that subscription's pending items; the twin sweeps every pending item of the customer |
POST /v1/invoices |
application_fee_amount |
— | a Connect fee splits the charge |
POST /v1/invoices |
transfer_data |
— | a Connect transfer moves money to another account |
POST /v1/invoices |
on_behalf_of |
— | Connect settlement on another account |
POST /v1/invoiceitems |
price_data |
— | an inline price the twin cannot read; pass amount + currency, or price |
POST /v1/invoiceitems |
unit_amount |
— | the twin reads amount (a total), or price × quantity |
POST /v1/invoiceitems |
unit_amount_decimal |
— | the twin reads amount (a total), or price × quantity |
POST /v1/invoiceitems |
discounts |
— | a discount changes the line total |
POST /v1/invoiceitems |
tax_rates |
— | tax changes the line total |
POST /v1/invoiceitems |
invoice |
— | adds the line to a specific draft invoice; the twin sweeps pending items only |
POST /v1/invoices/:id/pay |
paid_out_of_band |
false |
marks the invoice paid with no charge |
POST /v1/invoices/:id/pay |
forgive |
false |
writes off the remaining amount |
POST /v1/refunds |
refund_application_fee |
false |
a Connect fee refund |
POST /v1/refunds |
reverse_transfer |
false |
a Connect transfer reversal |
POST /v1/customers |
coupon |
— | a discount changes every future invoice |
POST /v1/customers |
promotion_code |
— | a discount changes every future invoice |
POST /v1/customers |
cash_balance |
— | a second balance the twin does not model |
POST /v1/customers/:id |
coupon |
— | a discount changes every future invoice |
POST /v1/customers/:id |
promotion_code |
— | a discount changes every future invoice |
POST /v1/customers/:id |
cash_balance |
— | a second balance the twin does not model |
POST /v1/prices |
billing_scheme |
per_unit |
tiered pricing changes the amount per quantity |
POST /v1/prices |
tiers |
— | tiered pricing changes the amount per quantity |
POST /v1/prices |
tiers_mode |
— | tiered pricing changes the amount per quantity |
POST /v1/prices |
transform_quantity |
— | changes the billed quantity |
POST /v1/prices |
recurring[usage_type] |
licensed |
metered billing bills reported usage, not the quantity |
POST /v1/prices |
recurring[aggregate_usage] |
— | metered billing; a Plans-era parameter Stripe's current API no longer accepts on a price, refused rather than echoed |
POST /v1/prices |
recurring[meter] |
— | metered billing |
POST /v1/prices |
recurring[trial_period_days] |
— | a trial every subscription created with trial_from_plan takes; a trial defers the first charge |
POST /v1/prices |
unit_amount_decimal |
— | the twin reads unit_amount |
POST /v1/prices |
custom_unit_amount |
— | the customer chooses the amount |
POST /v1/prices |
currency_options |
— | multi-currency prices |
POST /v1/prices |
product_data |
— | an inline product the twin cannot create; create the product, then pass product |
Known gaps
Behavior on the implemented surface that is known to differ from Stripe and is written down here rather than fixed yet; each row names its follow-up.
| gap | today | Stripe | follow-up |
|---|---|---|---|
DELETE /v1/customers/:id leaves the customer's subscriptions active |
the stub is deleted: true; the subscriptions keep renewing on every clock advance, billing a deleted customer |
"Also immediately cancels any active subscriptions on the customer" | a drift probe (customer-delete-cancels-subscriptions) and the fix, under C2 (whatever live drift flags) |
every recurring price echoes recurring.aggregate_usage: null |
priceShape in packages/server/src/twin/build.ts still emits the field, though POST /v1/prices refuses the parameter |
the current API neither accepts nor returns it | dropped when the price shape is next re-pinned against the drift battery |
| deleting a draft invoice strands the items it swept | the items keep invoice: <the deleted id> — not pending (?pending=true misses them), on no invoice; the next POST /v1/invoices answers Nothing to invoice |
unverified: returned to pending, or deleted with the draft | a drift probe decides; until then, void instead of delete, or create the items after deleting the draft |
The tickets surface (/api/v2/*) — demo-grade, by design
Worlds also expose a Zendesk-shaped support-desk surface so composite ticket+billing agent runs can be graded by one diff: GET/POST /api/v2/tickets.json, GET/PUT /api/v2/tickets/{id}.json, GET /api/v2/tickets/{id}/comments.json, with Zendesk's envelopes, ISO timestamps (from the logical clock), status/priority lifecycles (closed tickets are immutable), and Zendesk-shaped errors.
This surface is demo-grade: faithful enough for realistic agent traffic, deterministic like everything else, but — unlike the Stripe surface — not drift-verified against the real Zendesk API, and deliberately simplified (numeric-string ids, comments stored on the ticket, no users/orgs/macros/search, no page-based pagination). It graduates to verified status only when a design partner needs it to; until then, treat "the twin behaves exactly like Zendesk" as out of scope.