Simulators
Stripe simulator
stripe-twin is a local, stateful simulator of the Stripe API — real lifecycle, conflict and error paths, not stubs.
Quickstart
You need an API key from Account → API keys (how).
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
rystic run stripe-twin -d --port 0 --scenario design_studio # prints the URL it serves on
curl -s http://localhost:<port>/_rystic/version
Expect "product":"stripe-twin" and "version":"0.0.18". An older version means an older install; rystic pull stripe-twin upgrades it. --scenario design_studio boots the same world on every plan. The examples below use localhost:8080; use the port rystic run printed.
On Enterprise, rystic login ./rystic-license.json takes the place of the key (license files).
Point the SDK at it — base URL only, code unchanged:
stripe.api_key = "sk_test_twin"
stripe.api_base = "http://localhost:8080"
const stripe = require("stripe")("sk_test_twin", {
host: "localhost", port: 8080, protocol: "http",
});
Plans
Every plan (plans) serves all 194 Stripe API routes and every control route but /_rystic/network and /_rystic/ws-drop, which are Pro and Enterprise (routes by plan). Without --scenario the twin boots an empty account, holding only what every Stripe account starts with: a zero usd balance and Stripe’s 24 default Radar value lists. POST /_rystic/reset wipes it back to that; load design_studio with --scenario design_studio or POST /_rystic/scenario.
Auth
The twin reads your key as Stripe does, from Authorization: Bearer <key> or as the HTTP Basic username, and checks no key’s value: send a placeholder sk_test_… so your SDK builds well-formed requests; -u sk_test_twin: in the examples is Stripe’s curl form. Send one on every request: the invoice, invoice item, payout, product and credit balance transaction lists refuse a request with none, as Stripe refuses it on every route.
Coverage
194 routes across 46 resources; the table lists the payments core and the money it moves. GET /_rystic/routes returns every route from a running twin.
| Resource | Routes |
|---|---|
| Customers (5) | POST /v1/customers, GET /v1/customers, GET /v1/customers/{customer}, POST /v1/customers/{customer}, DELETE /v1/customers/{customer} |
| PaymentIntents (7) | POST /v1/payment_intents (incl. confirm=true), GET /v1/payment_intents, GET /v1/payment_intents/{intent}, POST /v1/payment_intents/{intent}, POST /v1/payment_intents/{intent}/confirm, POST /v1/payment_intents/{intent}/capture, POST /v1/payment_intents/{intent}/cancel |
| Charges (4) | POST /v1/charges, GET /v1/charges, GET /v1/charges/{charge}, POST /v1/charges/{charge} |
| Refunds (4) | POST /v1/refunds, GET /v1/refunds, GET /v1/refunds/{refund}, POST /v1/refunds/{refund} |
| PaymentMethods (5) | POST /v1/payment_methods, GET /v1/payment_methods/{payment_method}, POST /v1/payment_methods/{payment_method}, POST /v1/payment_methods/{payment_method}/attach, POST /v1/payment_methods/{payment_method}/detach |
| SetupIntents (6) | POST /v1/setup_intents, GET /v1/setup_intents, GET /v1/setup_intents/{intent}, POST /v1/setup_intents/{intent}, POST /v1/setup_intents/{intent}/confirm, POST /v1/setup_intents/{intent}/cancel |
| SetupAttempts (1) | GET /v1/setup_attempts |
| Checkout Sessions (6) | POST /v1/checkout/sessions, GET /v1/checkout/sessions, GET /v1/checkout/sessions/{session}, POST /v1/checkout/sessions/{session}, POST /v1/checkout/sessions/{session}/expire, GET /v1/checkout/sessions/{session}/line_items |
| Payment Links (5) | POST /v1/payment_links, GET /v1/payment_links, GET /v1/payment_links/{payment_link}, POST /v1/payment_links/{payment_link}, GET /v1/payment_links/{payment_link}/line_items |
| Checkout Session line items | GET /v1/checkout/sessions/{session}/line_items; POST /v1/checkout/sessions writes one per line_items element |
| Payment Link line items | GET /v1/payment_links/{payment_link}/line_items; POST /v1/payment_links writes one per line_items element |
| WebhookEndpoints (5) | POST /v1/webhook_endpoints, GET /v1/webhook_endpoints, GET /v1/webhook_endpoints/{webhook_endpoint}, POST /v1/webhook_endpoints/{webhook_endpoint}, DELETE /v1/webhook_endpoints/{webhook_endpoint}; connect=true makes a Connect endpoint, which renders application (a stand-in ca_ id) and takes only connected accounts’ events, every other endpoint only the platform’s |
| Events (2) | GET /v1/events, GET /v1/events/{id} — seeded events plus every event your requests trigger, each read only as the account it belongs to |
| v2 Events (2) | GET /v2/core/events (by object_id, types or created, newest first, 10 a page unless limit says otherwise, paged by next_page_url), GET /v2/core/events/{id} — every event your requests trigger, as a v2.core.event under its own evt_test_ id, each read only as the account it belongs to |
| v2 Event destinations (8) | POST /v2/core/event_destinations, GET /v2/core/event_destinations, GET /v2/core/event_destinations/{id}, POST /v2/core/event_destinations/{id}, DELETE /v2/core/event_destinations/{id}, POST /v2/core/event_destinations/{id}/enable, POST /v2/core/event_destinations/{id}/disable, POST /v2/core/event_destinations/{id}/ping; a URL whose host is localhost answers 400 “Invalid URL”, as Stripe’s does, so register 127.0.0.1 |
| Products (2) | POST /v1/products, GET /v1/products/{id} |
| Prices (3) | POST /v1/prices, GET /v1/prices, GET /v1/prices/{price} |
| Subscriptions (6) | POST /v1/subscriptions, GET /v1/subscriptions, GET /v1/subscriptions/search, GET /v1/subscriptions/{subscription_exposed_id}, POST /v1/subscriptions/{subscription_exposed_id}, DELETE /v1/subscriptions/{subscription_exposed_id} |
| Subscription items (1) | POST /v1/subscription_items/{item}; POST /v1/subscriptions writes one per items element |
| Customer portal sessions (1) | POST /v1/billing_portal/sessions; the session’s url opens the hosted portal (see Limitations) |
| Billing meter events (1) | POST /v1/billing/meter_events |
| Invoices (12) | POST /v1/invoices, GET /v1/invoices, GET /v1/invoices/search, GET /v1/invoices/{invoice}, GET /v1/invoices/{invoice}/lines, POST /v1/invoices/{invoice}, DELETE /v1/invoices/{invoice}, POST /v1/invoices/{invoice}/add_lines, POST /v1/invoices/{invoice}/finalize, POST /v1/invoices/{invoice}/pay, POST /v1/invoices/{invoice}/void, POST /v1/invoices/{invoice}/mark_uncollectible |
| Invoice payments (2) | GET /v1/invoice_payments, GET /v1/invoice_payments/{invoice_payment}; each names the PaymentIntent that pays its invoice, and a paid_out_of_band pay adds a paid one of type payment_record and cancels the PaymentIntent’s, neither then the default |
| Invoice items (5) | POST /v1/invoiceitems, GET /v1/invoiceitems, GET /v1/invoiceitems/{invoiceitem}, POST /v1/invoiceitems/{invoiceitem}, DELETE /v1/invoiceitems/{invoiceitem}; an item becomes a line of its invoice |
| Credit notes (8) | POST /v1/credit_notes, GET /v1/credit_notes, GET /v1/credit_notes/{id}, POST /v1/credit_notes/{id}, POST /v1/credit_notes/{id}/void, GET /v1/credit_notes/preview, GET /v1/credit_notes/preview/lines, GET /v1/credit_notes/{credit_note}/lines |
| Disputes (3) | GET /v1/disputes, GET /v1/disputes/{dispute}, GET /v1/charges/{charge}/dispute (Limitations 4) |
| Balance (1) | GET /v1/balance (Limitations 3) |
| Payouts (5) | POST /v1/payouts, GET /v1/payouts, GET /v1/payouts/{payout}, POST /v1/payouts/{payout}, POST /v1/payouts/{payout}/cancel; each writes its payout balance transaction (Limitations 3) |
| Radar value lists (5) | POST /v1/radar/value_lists, GET /v1/radar/value_lists, GET /v1/radar/value_lists/{value_list}, POST /v1/radar/value_lists/{value_list}, DELETE /v1/radar/value_lists/{value_list}; the 24 default lists can’t be updated or deleted |
| Radar value list items (4) | POST /v1/radar/value_list_items, GET /v1/radar/value_list_items, GET /v1/radar/value_list_items/{item}, DELETE /v1/radar/value_list_items/{item}; an address on email_blocklist, or a card fingerprint on card_fingerprint_blocklist, refuses a payment that carries it |
| v2 Accounts (5) | POST /v2/core/accounts, GET /v2/core/accounts, GET /v2/core/accounts/{id}, POST /v2/core/accounts/{id}, POST /v2/core/accounts/{id}/close; a request whose Stripe-Account or Stripe-Context names an account the key created acts as that account, whose PaymentIntents, charges, refunds, balance, balance transactions, balance settings, payouts and events are its own, and one naming an account the key closed answers 403 account_invalid, as one the key does not hold does; include=requirements answers {entries, summary}, and a stored identity keeps only each ID number’s type, never its value |
| External accounts (1) | POST /v1/accounts/{account}/external_accounts, a bank account for a v2 account; create only, as GET /v1/accounts/{account}/external_accounts is not served (“Unrecognized request URL”) |
| Balance settings (2) | GET /v1/balance_settings, POST /v1/balance_settings, one per account; a connected account’s read payments.payouts.status enabled and payments.debit_negative_balances false once its stripe_balance.payouts capability is active, disabled and true before |
Products
Invoices, invoice items, credit notes, the balance, payouts, dispute reads, Radar value lists and Connect’s v2 accounts are modelled: acting as a connected account, its capabilities, bank account, balance and payouts, destination charges, and an account with the customer configuration paying as customer_account. Connect’s v1 accounts, the transfer and application fee routes, quotes, tax and dispute updates are not.
Control plane
Every route under /_rystic/ is listed in routes by plan. GET /_rystic/egress holds the webhook events your requests triggered (Egress log), /_rystic/ui shows the account as the Stripe Dashboard (The Dashboard UI), and Stripe has no WebSocket feed, so /_rystic/ws lists none.
Scenarios
design_studio is the one shipped world: Website Design Contractors’ own account on 2026-03-01T00:00:00Z. 30 clients as customers (5 paying by US bank account), 22 billed monthly off a saved method; 269 charges since November 2024, each intent naming its invoice, with one card declined and paid on a replacement, one refunded in full, one disputed and one bank debit still pending; 7 unpaid invoices as open intents; and the invoice tracker’s webhook endpoint. The same clients, invoice numbers and dates appear in the studio’s Slack, Resend, PayPal and Lob worlds.
curl -s 'localhost:8080/v1/charges?limit=1' -u sk_test_twin:
# -> "description":"Invoice WDC-1328: Hosting, renewal", "metadata":{"invoice":"WDC-1328"}
Worlds covers listing and loading worlds.
Seeding state
One register per resource: state_balance, state_billing_alert, state_billing_credit_balance_transaction, state_billing_credit_grant, state_billing_meter, state_billing_meter_event, state_billing_meter_event_adjustment, state_billing_portal_configuration, state_billing_portal_session, state_charge, state_checkout_session, state_checkout_session_line_item, state_credit_note, state_credit_note_line_item, state_customer, state_discount, state_dispute, state_entitlements_feature, state_event, state_event_destination, state_invoice, state_invoice_line_item, state_invoice_payment, state_invoiceitem, state_payment_intent, state_payment_intent_amount_details_line_item, state_payment_link, state_payment_link_line_item, state_payment_method, state_payout, state_price, state_product, state_product_feature, state_radar_value_list, state_radar_value_list_item, state_refund, state_setup_attempt, state_setup_intent, state_subscription, state_subscription_item, state_test_clock, state_v2_core_event, state_webhook_endpoint. Worlds covers seed files, POST /_rystic/seed, GET /_rystic/registers and the seed gate. A boot file with one customer, a pinned clock and a fixed RNG:
{
"seed": {
"state_customer": { "cus_dev": { "id": "cus_dev", "object": "customer",
"email": "dev@example.com" } }
},
"clock": "2026-07-23T00:00:00Z",
"rng_seed": 42
}
Determinism & reset
Ids are sequences that restart on reset, and timestamps come from the twin’s clock: an empty boot starts at 2026-01-01T00:00:00Z, design_studio at 2026-03-01T00:00:00Z, and each request that stamps a time (a write) moves it one millisecond, never by wall time. The same requests from the same reset give byte-identical records, ids, timestamps and events. Reset and determinism covers reset, the clock and the RNG.
Egress log
Every event your requests trigger renders into one ordered log, and GET /_rystic/egress returns it. This is the webhook surface. Boot the twin with RYSTIC_WEBHOOK_DELIVERY=on (--env RYSTIC_WEBHOOK_DELIVERY=on) and each event is also POSTed to every enabled webhook endpoint you registered whose enabled_events names it or *, signed with that endpoint’s secret in Stripe-Signature, and to every enabled thin event destination whose enabled_events names its type, v1.-prefixed for a v1 event, as a thin notification signed with the destination’s signing_secret; without it nothing is POSTed. A v2 account event (v2.core.account.created, .updated, .closed, and each [part].updated and capability_status_updated one) has no v1 snapshot and reaches no webhook endpoint, but a thin destination whose enabled_events names it receives it, and stripe-node’s parseEventNotification, fetchEvent and fetchRelatedObject work against it. An event caused while acting as a connected account is that account’s (Limitation 1), and only connect=true endpoints take it. The signature’s t= is wall-clock time, so stripe.webhooks.constructEvent accepts it however the twin’s clock is pinned, while the event’s own created stays on the twin’s clock. A failed delivery is retried as Limitation 1 describes, each retry signed again as it is sent. Webhooks covers delivery and Observing the log.
{"dropped":0,"events":[{"seq":1,"effect":"eff_evt_customer_created","type":"customer.created",
"payload":{"created":1767225600,
"data":{"object":{...,"email":"dev@example.com","id":"cus_lllllllllllllc",...,"object":"customer",...}},
"id":"evt_lllllllllllllc","object":"event","type":"customer.created"}}],
"total":1}
The routes below emit, in this order; the table is not exhaustive (subscriptions, invoices, credit notes, test clocks and v2 accounts emit too), and the egress log carries every route’s events:
| Request | Events |
|---|---|
POST /v1/customers | payment_method.attached when payment_method attaches one, then customer.created |
DELETE /v1/customers/{customer} | customer.deleted, then customer.subscription.deleted for each subscription it cancels |
POST /v1/charges | charge.succeeded |
POST /v1/charges/{charge} | charge.updated |
POST /v1/payment_intents | payment_intent.created; with confirm=true, then charge.succeeded, and payment_intent.succeeded unless capture_method=manual |
POST /v1/payment_intents/{intent}/confirm | charge.succeeded, then payment_method.attached when it attaches its PaymentMethod to the customer, and payment_intent.succeeded unless capture_method=manual; when the PaymentIntent pays an open invoice, then invoice_payment.paid, invoice.updated, invoice.paid, invoice.payment_succeeded, and customer.subscription.updated when that activates an incomplete subscription |
POST /v1/payment_intents/{intent}/capture | charge.captured, payment_intent.succeeded |
POST /v1/payment_intents/{intent}/cancel | payment_intent.canceled |
POST /v1/refunds | refund.created, charge.refunded, refund.updated, charge.refund.updated |
POST /v1/refunds/{refund} | refund.updated, charge.refund.updated |
POST /v1/setup_intents | setup_intent.created |
POST /v1/setup_intents/{intent}/confirm | setup_intent.succeeded |
POST /v1/setup_intents/{intent}/cancel | setup_intent.canceled |
POST /v1/payment_methods/{payment_method}/attach | payment_method.attached |
POST /v1/checkout/sessions/{session}/expire | checkout.session.expired |
POST /v1/products | product.created; with default_price_data, first plan.created (for a recurring default price) and price.created for the price it mints (Stripe’s refusals of a malformed default_price_data are not modelled yet) |
POST /v1/products/{id} | product.updated, with previous_attributes over the fields it changed |
DELETE /v1/products/{id} | product.deleted, carrying the product as it stood |
POST /v1/prices | plan.created for a recurring price, carrying the legacy Plan view of the price under the same id, then price.created; a create with product_data does not send the product’s product.created yet |
POST /v1/prices/{price} | plan.updated for a recurring price, then price.updated, each with previous_attributes over the fields it changed |
payload is the envelope Stripe would have POSTed, data.object the resource as the API returned it.
The Dashboard UI
Open http://localhost:8080/_rystic/ui in a browser: the
Dashboard card draws the twin’s state as the Stripe Dashboard — Home with
volume, payment and customer charts, Payments and Refunds, Customers, Payment
links, Checkout sessions, and Developers → Webhooks and Events — and redraws
as your integration calls the API.
- Act as the Dashboard does. Refund all or part of a payment, capture or
cancel an intent, add, edit or delete a customer, create or deactivate a
payment link, expire a Checkout Session, and add, update, disable or delete
a webhook endpoint. Each goes through the twin’s own
/v1API with a placeholder key, so it lands like your integration’s call: it takes the next id and the twin’s clock, renders its events to egress and shows inGET /_rystic/calls. Each dialog’s footer names the call it makes. The twin has one account, so the UI sends noX-Rystic-Actor. Amounts must be positive with no more fractional digits than the currency supports. - Find anything. The search bar jumps to any object id and matches descriptions, emails and amounts. Every detail page opens a JSON drawer with the raw records. Customer spend, refunds and disputes are shown separately for each currency; currencies are never added together.
- Events. The Events page lists the seeded
/v1/eventsrecords and the events your requests caused (/_rystic/egress) side by side, marked by source. Endpoint pages match the newest 300 retained events against current subscriptions, including events from before the endpoint existed. These matches change with endpoint settings and are not delivery history; see Egress log for what is delivered. - Your integration lists the live API calls, Webhook egress lists the rendered events and the endpoints that subscribe to each, and Clock shows and moves the twin’s clock and resets or loads a world.
- What the page does not run — balances, payouts, disputes (it shows a disputed charge’s flag), products, billing, receipts and hosted payment pages — is drawn but inert: a click says so. The page contacts nothing but the twin.
- Times are UTC, and “today” is the twin’s clock. A reset or scenario load closes open dialogs and detail pages; a reply from the previous world cannot change the new one.
Limitations
- Webhook delivery is opt-in. Endpoint CRUD is modelled, and the events your requests trigger render to the ordered egress log; they reach your endpoints only under
RYSTIC_WEBHOOK_DELIVERY=on. A delivery your endpoint does not answer with a 2xx within 5 s is retried three times, 30 min, 1 h and 2 h after each failure, or as soon as/_rystic/clockmoves the clock past a retry’s due; each retry carries a newStripe-Signaturemade as it is sent, and none goes to an endpoint or destination deleted or disabled by then. Stripe documents three sandbox retries over a few hours but not their delays, so these are assumed. Each event is also listed byGET /v1/eventsandGET /v1/events/{id}under the id its delivery carries, beside whatever a seed put instate_event, and byGET /v2/core/eventsandGET /v2/core/events/{id}as av2.core.eventunder its ownevt_test_id. A request’s events keep the twin clock’screated, and an event the clock sends (a subscription’s renewal, trial orcancel_at, a Checkout Session’s expiry, a pending refund’s settlement) carries the instant it was due, however far a/_rystic/clockmove jumps past it; one a test clock’s advance sends carries the twin clock’s time at the request that completes the advance, as Stripe’s carry real time. A v1 event’spending_webhookscounts the enabled webhook endpoints subscribed to it when it was logged, less each that has taken it with a 2xx (thin destinations count nothing, and a failed attempt lowers nothing), sodelivery_success=falselists an event still waiting on a retry or whose retries all failed; with delivery off, nothing lowers it. An event a request causes while acting as a connected account (Stripe-AccountorStripe-Context) is that account’s: its v1 snapshot carriesaccountandcontextnaming it, its v2 renderingcontext, and the event lists are its own, as is every event the subscription clock sends for a subscription created acting as an account (Limitation 10) and every event a pending refund’s settlement sends for a refund made acting as one. The events of the platform’s own requests are the platform’s and name no account, and so aretest_helpers.test_clock.readyandcheckout.session.expired, even for a test clock or Checkout Session made acting as an account; which account Stripe gives those two is not yet measured. A clock act for the platform stays the platform’s when a connected account’s request brings the clock to it, such as the request that completes a test clock’s advance. A webhook endpoint created withconnect=truetakes only connected accounts’ events, and every other endpoint only the platform’s. A destination charge’spy_payment emitspayment.createdas the connected account’s, withrequest{id: null, idempotency_key: null}: it lists among the account’s events and reaches the platform’s Connect endpoints, never the platform’s own list; Stripe lists it about 3 s after the charge, the twin at once. A webhook endpoint atlocalhostis accepted, where Stripe refuses it with “URL must be publicly accessible”; an event destination there is refused as Stripe refuses it. - Little moves on its own. Drive time via
/_rystic/clock. The clock settles asepa_debitpayment and a verifiedus_bank_accountone (Limitation 11), and moves a subscription’s trial, renewal, draft invoices and resume expiry (Limitation 10), a subscription reaching itscancel_at, whichcancel_at_period_end=truesets to the period end (Limitation 8), and a Checkout Session reaching itsexpires_at, 24 hours after its create unless the create sets it (an integer from 30 minutes to 24 hours ahead; a seeded session with none reads a day after itscreated), when an open session readsexpiredandcheckout.session.expiredgoes out, stamped with thatexpires_atwhen a/_rystic/clockmove reaches it. A connected seller’s capabilities activate on it too. A seller created with its onboarding details (an individual identity with date of birth, address, ID number and terms-of-service acceptance, and a business URL) readscard_paymentspendinguntil 85 seconds after its create, thenactive. Once it has an external account, itspayoutsreadpendingfrom 18 seconds andactivefrom 85. Each capability whose status moves sends itscapability_status_updatedevent, as Stripe’s activation does, and acard_paymentsre-requested after Stripe unfreezes it readsactiveat once on a seller past its activation; on one frozen before it,card_paymentsreadspendingagain, andpayoutstoo while it holds a bank, until the seller’s own due activates them. Until activation, a charge as the seller answers “Your account cannot currently make charges.” Advance the clock past it withPOST /_rystic/clock {"advance_seconds": 85}, or run withRYSTIC_PACE=1so the clock follows real time. A refund of a capturedpm_card_pendingRefund/tok_pendingRefundcharge, on any create route or through a credit note’srefund_amount, answerspendingwithpending_reasoninsufficient_fundsand a card destination naming no acquirer reference yet; once the clock reaches 122 seconds after itscreatedit readssucceeded, and at 249 seconds its acquirer reference lands, each move sendingrefund.updatedandcharge.refund.updated. Stripe’s own instants vary by a few seconds. A legacyPOST /v1/charges/{charge}/refundrefund of a charge whose balance transaction has not attached yet (only a seeded charge, as the twin attaches one at its confirm) issucceededat once with no balance transaction, and on the pendingRefund card its reference stayspending. A test clock’s advance completes by itself: the next API request still reads the clockadvancingwith its oldfrozen_time(an advance sent then answers 429, as Stripe’s does), and the request after it reads the clockreadyat the target, having moved the customers and subscriptions on the clock to it (Limitation 10). A request counts once the twin hands it to an operation, a hosted page or/_dispute, even when that handler refuses it (a 401 without a key, say), and so does a world timeline call to an API route when it fires (a sim-local one such as/_disputedoes not). A request the twin answers before that (an unserved path or method, an admin-token refusal, an oversized body, a/_rystic/networkreject), a/_rystic/control call (seed, clock, reset, inspect, version, or an unknown/_rystic/path), a clock jump or a paced tick (RYSTIC_PACE) that fires no timeline call, and a read of the state UI, its activity and events streams, the explorers or a file URL leave the clock advancing. An advance takesfrozen_timeas an integer as sent (+<t>reads as<t>; a space-prefixed, empty, array or hash value answers 400parameter_invalid_integer, the value echoed as Stripe renders it,{:a=>"<t>"}forfrozen_time[a]), from 2000-01-01 to 2100-01-01, not before the clock’s time (its own time is accepted) and at most two of the shortest recurring interval of the clock’s live subscriptions on: two calendar months for a monthly one, six years on a clock holding none. A clock still advancing answers its 429 beforefrozen_timeis read. A clock create takes the same integers and range, and answers a negative time 400 “Invalid timestamp: must be an integer”. - The balance moves with card payments, refunds and payouts. Every account starts with a
usdentry at zero. A connected account’s balance also listsinstant_availablebesideavailable, and the platform’s listsconnect_reservedat zero. A captured card payment adds its amount less Stripe’s 2.9% + 30¢ fee, toavailablefor thetok_bypassPending/pm_card_bypassPendingtest card (card 4000000000000077, also when saved as a PaymentMethod) and topendingfor every other card and for asepa_debitpayment once the clock settles it (Limitation 11); a verifiedus_bank_accountpayment adds its amount less Stripe’s ACH fee toavailableonce the clock settles it; pending funds never become available on the twin’s clock. A destination charge (a PaymentIntent withtransfer_data[destination], paid at create, confirm or capture) credits the platform its net as any card payment does, then transferstransfer_data[amount](capped at the captured amount) or else the whole amount to the connected account, which gains it less anyapplication_fee_amount; sending both answers 400parameters_exclusive, as Stripe does. The platform lists atransferbalance transaction for minus the share and, with a fee, anapplication_feeone for plus it; the account lists apaymentone whose source is thepy_payment it reads as a Charge. The twin moves both at the charge, where Stripe shows them seconds later. A refund withreverse_transfer=truetakes its proportion of the share back from the account (atrr_reversal the refund’stransfer_reversalnames, and apyr_refund of the payment), andrefund_application_fee=truereturns its proportion of the fee, each rounded half up on its own, the refund that leaves the transfer fully reversed returning the rest of the fee; Stripe refuses a reversal that rounds to 0 and a fee refund withoutreverse_transfer, which the twin accepts; withoutreverse_transferthe platform alone bears the refund, and Stripe’sreserve_transactionrows are not modelled. A refund takes its amount back out; an invoice paid by card credits the balance as a card payment does, and a credit note’srefund_amountdebits it as a refund does. A payout draws onavailableor answersbalance_insufficient; the create answers itpendingand every later read finds itpaid, as test mode pays a payout at once, so a cancel answers 400 “Payouts can only be canceled while they are pending.” A payout writes apayoutbalance transaction for minus its amount, the one itsbalance_transactionnames, and a cancel that succeeds apayout_cancelone; a connected account’s payout names asdestinationthe bank account its external account create returned, andarrival_dateis midnight UTC of the payout’s day. Disputes do not move the balance, and a charge in another currency is credited in that currency rather than converted tousd. - A dispute test card opens a chargeback; nothing moves it after. A captured charge paid with
tok_createDispute(POST /v1/charges) orpm_card_createDispute(a PaymentIntent created withconfirm=true, or confirmed) opens aneeds_responseDispute at once: the charge readsdisputed: trueand names it indispute,charge.dispute.createdis sent, andGET /v1/disputes,GET /v1/disputes/{dispute}andGET /v1/charges/{charge}/disputereturn it. The charge create itself answersdisputed: false, as Stripe does before the chargeback lands, and a charge with no dispute answers its dispute read with 404 “No dispute for charge”. No API call updates or closes a Dispute. A world can also open one:design_studioholds a disputed charge with its Dispute, andPOST /_dispute {"charge": "ch_…", "reason"?, "amount"?}on a captured charge stores the same Dispute, marks the charge and sendscharge.dispute.createdto the egress log and your registered endpoints. A world timeline schedules it as a sim-local call. Otherwise it is a control route like/_rystic/*: open by default, and once the twin boots withRYSTIC_ADMIN_TOKENset, a request needs that value in anX-Rystic-Admin-Tokenheader. It answers 400 withoutcharge, for an unknownreasonor for anamountoutside 1 to the amount captured, 404 for an unknown charge, and 409 for one that took no funds or is already disputed. Refunding a disputed charge answers Stripe’s 400charge_disputed. - Test-mode semantics. Converged against
api.stripe.comtest mode; live-only behavior is out of scope. - One process = one account world. Run multiple ports for isolation.
- Hosted Checkout completes subscriptions only. A Checkout Session’s
urlopens the twin’s own page at/c/pay/{session}on the origin you called, with the line items, total, prefilled email and a link back tocancel_url. Subscribing on asubscription-mode session creates the customer and a subscription carrying the session’s prices, quantities andsubscription_data[metadata], completes the session and redirects tosuccess_url, emittingcustomer.created,customer.subscription.createdandcheckout.session.completedin that order; any card number is accepted. Submitting apaymentorsetupsession answers 501 and the session staysopen. - The customer portal changes seats and cancels. A portal session’s
urlopens the twin’s own portal at/p/session?secret=…on the origin you called, listing the customer’s subscriptions with their seats and renewal date. Changing seats, cancelling (which setscancel_atto the period end and leavescancel_at_period_endfalse, as Stripe’s portal does) and taking a cancellation back are each a subscription update that emitscustomer.subscription.updatedto your registered endpoints. Once the clock reaches a subscription’scancel_at, it readscanceledandcustomer.subscription.deletedgoes out. The portal shows no payment method, billing details or invoice history. - Hosted invoice, receipt and payment link URLs lead to the twin. A finalized invoice’s
hosted_invoice_urlandinvoice_pdf, a credit note’spdf, a charge’sreceipt_urland a payment link’surlare on the origin you called, never oninvoice.stripe.com,pay.stripe.comorbuy.stripe.com. The twin serves no page at those paths: they answer 404. - A subscription bills its first invoice at create, and its trial and renewals run on the clock.
POST /v1/subscriptionswrites the first invoice andlatest_invoiceretrieves it. Withcharge_automaticallyand a card (the subscription’sdefault_payment_method, else the customer’sinvoice_settings.default_payment_method), the invoice is paid at once by a PaymentIntent and charge, the charge credits the balance, and the create sendspayment_intent.created,charge.succeeded,payment_intent.succeeded,customer.updated(only when the customer had no currency yet or a balance the invoice spends, Limitation 12),invoice.finalized,invoice.created,invoice_payment.paid,invoice.paid,invoice.payment_succeededandcustomer.subscription.created, in that order, itspayment_intent.createdcarrying the PaymentIntent as created, before the charge. Acharge_automaticallycreate with no card and no trial answers Stripe’s 400resource_missingonce the world holds any PaymentMethod; in a world that holds none it readsactivewith an open first invoice. Asend_invoicesubscription’s first invoice is a draft that finalizes an hour after its create. Abilling_cycle_anchorahead of a create with no trial runs the first period to the anchor, billed as a proration of the interval that ends there. A trial (trial_period_days, or atrial_endafter the create’s clock, its customer’s test clock’sfrozen_timewhen it is on one) needs no card: its first invoice is paid at zero with lines readingFree trial for <quantity> × <product>, its items run totrial_end, itsbilling_cycle_anchoristrial_endunless the create sets one, and a customer with no currency takes the subscription’s. With no card, acharge_automaticallytrial also mints a pending SetupIntent,requires_payment_methodwithusageoff_session, named inpending_setup_intentand announced bysetup_intent.createdbefore the create’s other events; nothing later clears it. Asend_invoicetrial is unmeasured and keepspending_setup_intentnull, and the succeeded SetupIntent Stripe makes for a trial with a card is not modelled. A create answers Stripe’s 400 totrial_endbesidetrial_period_days, and to atrial_endat or before its clock withparamtrial_end. A create’scancel_atrecords a cancellation request,canceled_atthe create’s time; one before the first period’s end ends that period there, billed as a proration (a trial keeps itstrial_endand anchor, its zero line running tocancel_at), and the clock ends the subscription atcancel_atwith no further invoice;min_period_end,max_period_endormax_billed_untilsets it to the first period’s end, billing that period whole; one at or before the create’s clock, or besidecancel_at_period_end=true, answers Stripe’s 400. Before it looks anything up, a create also answers Stripe’s 400 to, in this order, an emptycollection_method(parameter_invalid_empty) or one other thancharge_automaticallyorsend_invoice, an emptytrial_endor one that is neithernownor a unix time, and an emptycancel_ator one that is neither a unix time normin_period_end,max_period_endormax_billed_until, each naming only that parameter even beside one it conflicts with. A latertrial_endon an update moves the anchor, the period end and a period-endcancel_atwith it and bills the added days at zero; one at or before the subscription’s clock answers Stripe’s 400 withparamtrial_end, and one that is neithernownor a unix time answers 400 “Invalid trial_end: must be one of now”, as the twin answers a hash or an array too (Stripe’s answer to those is unmeasured). An update whoseitems[i][id]is empty answers 400parameter_invalid_empty. Seven days before a renewal’s period end,invoice.upcominggoes out naming no request, once per period end, previewing the renewal invoice; the lead is the account’s “upcoming renewal events” setting, which the twin does not model and fixes at seven days, and a subscription set to cancel at its period end, or a period shorter than seven days, gets none. Three days beforetrial_end(at once for a shorter trial, and on a test clock at its next advance) the clock sendscustomer.subscription.trial_will_end, afterinvoice.upcomingwhen the trial’s end will bill and the trial runs three days or more. Attrial_enda subscription with a card, asend_invoiceone, or one whosetrial_settings[end_behavior][missing_payment_method]iscreate_invoice(the default) readsactiveand renews, billing a proration up to abilling_cycle_anchoraftertrial_endfirst; with no card undercharge_automatically, or withpm_card_chargeCustomerFail(a test card that attaches but whose every charge is declined), the invoice’s payment fails as it finalizes an hour later, so it stays open withnext_payment_attempt42.1 h on, and the subscription readspast_dueand the customerdelinquent. The clock retries atnext_payment_attempt: with no card, or the declining one (inferred), the attempt fails again and the next is dated 42.1 h on, until the ninth attempt, which leaves the invoice open with no retry, cancels the subscription forpayment_failedand keeps the customerdelinquent; with a default card that charges, the retry pays the invoice. Voiding, marking uncollectible or paying the invoice reads the subscriptionactivein that request, and the customer not delinquent even while another of its subscriptions is stillpast_due. Ending a subscription, byDELETE, at itscancel_at, at its last failed retry or by its customer’s delete, stops automatic collection of every open invoice of the customer and of the subscription’s renewal draft, withinvoice.updatednaming no request, so the clock retries none of them and the draft never finalizes; a customer’s or a test clock’s delete marks the draftuncollectibleinstead, andcancel_at_period_end=truestops nothing before the period ends.DELETEwithinvoice_now=trueandprorate=true, in the body or the query string, credits each item’s unused time on a final 0.00 invoice paid an hour later onto the customer’sbalance, clearingdelinquent; acancel_atset inside the period cuts the items’ period there and leaves the credit pending until the clock ends the subscription and bills it the same way, stopping the open invoices once that invoice finalizes. With no card,pausepauses the subscription instead andcancelcancels it.trial_end=nowon an update ends a trial and bills the new period at once. When the clock reaches anactiveorpast_duesubscription’s period end, a draft renewal invoice is written and finalizes an hour later. Periods are counted frombilling_cycle_anchor, each ending on its day of the month or the month’s last day, so a Jan 31 anchor renews on Feb 28, Mar 31 and Apr 30. Each item renews on its own price’s interval, so a yearly item beside a monthly one is billed once a year, and an item added later takes the subscription’s current period; unlessproration_behaviorisnone, one added to anactiveorpast_duesubscription leaves a pending proration for the rest of the period that the next renewal bills first (underalways_invoice,POST /v1/subscription_itemsbills it at once instead, on an invoice paid from the card undercharge_automatically, paid with no payment when there is nothing to bill, or undersend_invoicea draft that finalizes an hour later, and undercharge_automaticallywith a proration above 0 and no stored payment method the add is refused with Stripe’s 400resource_missing, while an update’sitems[]add waits; a price or quantity change prorates nothing; deleting an item mid-period leaves a pending credit for its unused time, figured from what was last billed for it, and writes no invoice). An update whoseitems[]names another subscription’s item, or an id no item has, answers Stripe’s 400 and changes neither subscription, as does one whosecancel_atis not after the subscription’s clock (a test clock’sfrozen_timeon one) or that setscancel_at_period_end, true or false, orbilling_cycle_anchor=nowon apausedsubscription (with both, the anchor’s refusal answers); acancel_at_period_endthat is neithertruenorfalseanswers 400 “Invalid boolean:” before either. A renewal or a trial’s end on the clock whose next period would run past cancel_atbills only up tocancel_at, and a renewal into a period shorter than the price’s interval (after a clearedcancel_at) bills that period, each as a proration.billing_cycle_anchor=nowon an update of anactiveorpast_duesubscription restarts its period with no invoice and, unlessproration_behaviorisnone, leaves one pending proration for the days the new period adds, which the next renewal bills first; on apausedone it is refused, as above, and on any other it moves the anchor alone. An item whosecurrent_period_endis not a time, as a seeded record’s0, never renews. Invoice items created withPOST /v1/invoiceitemsstay pending, asubscriptionin the request included. A subscription the clock ends at itscancel_ator period end keeps the request’s time incanceled_at, withended_atthe end. A card declining on the clock, aspm_card_chargeCustomerFaildoes at a trial’s end or a renewal, leaves afailedcharge and the PaymentIntentrequires_payment_methodwith the decline as itslast_payment_error, sendingcharge.failedandpayment_intent.payment_failed, and paying the invoice with another card clears thatlast_payment_error; the clock’spayment_intent.createdcarries the PaymentIntent as created, before its charge. A request charging that card is declined the same way: a PaymentIntent created withconfirm=trueor confirmed, and an invoice pay, answer Stripe’s 402card_error(card_declined,generic_decline), and the invoice a pay names stays open with one attempt. A subscription create charging that card,trial_end=nowor not, readsincomplete, its first invoice open with one attempt and its PaymentIntentrequires_payment_method, and sendspayment_intent.created,charge.failed,payment_intent.payment_failed,customer.updated(only when the customer had no currency yet or a balance the invoice spends),invoice.payment_failed,invoice.created,invoice.finalizedandcustomer.subscription.created; underpayment_behavior=error_if_incompleteit answers the decline’s 402, leaves no subscription or invoice and cancels the PaymentIntent. Neither makes the customerdelinquent. Paying anincompletesubscription’s open invoice, by an invoice pay or by confirming its PaymentIntent with a card that charges, makes itactiveat once, reusing that PaymentIntent and invoice payment, and sendscharge.succeeded,payment_intent.succeeded,invoice_payment.paid,invoice.updated,invoice.paid,invoice.payment_succeededandcustomer.subscription.updated, a confirm sendingpayment_method.attachedafter the charge as it attaches the card. An update’strial_end=nowcharging the declining card is declined the same way, leaving its invoice open with one attempt and the subscriptionpast_due, its customer not madedelinquent, andinvoice.payment_failed, naming no request, follows the update’s own events. A resume underbilling_cycle_anchor=now(the default) leaves the subscriptionpausedwith apending_updateand an open invoice for the period it starts, which nothing charges on its own; once that invoice is paid or marked uncollectible the subscription readsactivefrom the resume, sendingcustomer.subscription.resumed, which carries the subscription as it stood before (trialing, as Stripe’s does), thencustomer.subscription.pending_update_appliedandcustomer.subscription.updated. Once the clock passespending_update.expires_at, 23 hours after the resume, an unpaid resume invoice is voided, its PaymentIntent canceled andpending_updatecleared, the subscription stayingpausedand sendingcustomer.subscription.pending_update_expired; aDELETEbefore then does the same before it cancels. A resume underbilling_cycle_anchor=unchangedreadsactiveat once with no invoice, on the anchor’s schedule, and unlessproration_behaviorisnonethe next renewal also bills the rest of the period it resumed in. A customer created withtest_clocklives on that clock: it and its subscriptions read the clock’sfrozen_timeincreated, their periods, trial dates and invoices, each invoice carries the clock’stest_clock, and only the clock’s advance moves them, every act due in it at its own instant on the clock, while their events and the PaymentIntents and charges paying their invoices keep the twin’s clock./_rystic/clockmoves only the subscriptions on no test clock. A subscription created acting as a connected account (Stripe-AccountorStripe-Context) settles as that account: the PaymentIntents and charges its renewals, trial end and later payments make are in that account’s lists and balance, not the platform’s, and every event its clock sends is the account’s. Deleting a customer cancels its live subscriptions at once, at the delete’s time on their clock, so none renews, and marks each one’sdraftinvoicesuncollectiblefirst, never finalized or charged (a void of one answers Stripe’s 400), leaving its pending invoice items pending; deleting a test clock deletes its customers and cancels the subscriptions on it, a deleted customer’s included. Either sendscustomer.deleted, carrying the customer as it stood, beforecustomer.subscription.deleted; a test clock delete sendstest_helpers.test_clock.deletedbetween them, its customers’ events oldest first and its subscriptions’ newest first. A test clock’s create sendstest_helpers.test_clock.created; its advance sendstest_helpers.test_clock.advancingat once and, when it completes, every event the completion settles, thentest_helpers.test_clock.ready. A customer create whosetest_clocknames no clock, or a deleted one, answers Stripe’s 400resource_missing, an emptytest_clock400parameter_invalid_emptyand a hash 400 “Invalid string: …”, and a Checkout Session’s expiry stays on the twin’s clock whatever its customer. - A
sepa_debitpayment settles on the clock, and aus_bank_accountone settles once its microdeposits are verified. A PaymentMethod created withtype=sepa_debitand aniban, or one a PaymentIntent’spayment_method_datamakes, reads the IBAN’s country, last four and bank code, as Stripe’s does. A PaymentIntent created withconfirm=trueand asepa_debitmethod answersprocessingwith apendingcharge, sendingpayment_intent.created,payment_intent.processingandcharge.pending. 15 seconds after the charge, the clock settles it as Stripe’s does:charge.updatedas it names its balance transaction, thenpayment_intent.succeededandcharge.succeeded, and the amount less the card fee moves topending. The test IBANDE62370400440532013001fails instead: the intent returns torequires_payment_methodwithlast_payment_error(payment_intent.payment_failed), the charge readsfailedwithfailure_codeincorrect_account_holder_name(charge.failed), thencharge.updated, and no balance moves; a charge whose intent was cancelled whileprocessingstayspending, and a payment made acting as a connected account settles the same way in that account, its settle events the account’s. Stripe settles 12 to 16 seconds after the charge and documents no delay; the twin’s fee follows the card’s rule. Aus_bank_accountPaymentIntent created withconfirm=true, a customer and no Financial Connections account answersrequires_actionwithverify_with_microdeposits, the deposits arriving at 07:00 UTC the next day (one capture’s rule) andhosted_verification_urlon the origin you called, where the twin serves no page. Verifying the microdeposits takesdescriptor_codeoramounts, never both or neither (Stripe’sparameters_exclusiveandparameter_missing400s), and answers Stripe’s test values, a code in any case and the amounts in either order:SM11AA, oramounts32 and 45, answersprocessingwith apendingcharge (payment_intent.processing,charge.pending), and 20 seconds after the charge the clock settles it as asepa_debitone succeeds, its balance transaction Stripe’s ACH payment (typepayment, a 0.8% fee, available 5 seconds after it is created); Stripe settled 15 to 39 seconds after the verify.SM33CC, oramounts10 and 11, answers Stripe’s attempts-exceeded 400 andSM44DD, oramounts40 and 41, the intent timed out, each returning the intent torequires_payment_methodwith afailedcharge (payment_intent.payment_failed,charge.failed); any other code or amounts answers Stripe’s mismatch 400 with the attempts left, and the tenth wrong verify fails the intent in its own request. Aus_bank_accountSetupIntent created withconfirm=trueawaits microdeposits too, and its verify takes the same values: the verifying ones succeed it at once, attaching the PaymentMethod to the customer (payment_method.attached,setup_intent.succeeded),SM33CCandSM44DDleave itrequires_payment_methodwith its PaymentMethod unattached (setup_intent.setup_failed), and a wrong code answers the mismatch 400 with its own ten attempts. Asepa_debitSetupIntent created withconfirm=truesucceeds at once. The confirm route with these methods, a verified payment failing on the clock, refunding asepa_debitcharge and a SetupIntent’s failing IBAN are not modelled. - A customer’s balance is applied to its next invoice. A customer’s
balance, a credit when negative and an amount owed when positive, is set byPOST /v1/customersor its update, lowered by a credit note’scredit_amount, or left by an invoice that ends below zero; the twin serves no/v1/customers/{customer}/balance_transactions. A draft invoice readsstarting_balanceas the customer’s balance when it is created,ending_balancenull, andamount_dueastotalplusstarting_balance, never below 0: after a -500 credit, a draft with a 1200 item readsamount_due700 withtotal1200. Finalizing reads the balance again, so a credit another invoice spent first is not applied twice: the invoice readsending_balance0, or the rest of a larger credit, its PaymentIntent asks for the reduced amount, the customer’sbalancebecomes the invoice’sending_balance, andcustomer.updatedgoes out before the finalize’sinvoice.updated, as Stripe’s does. A credit that covers the whole invoice leaves it paid at the finalize with no PaymentIntent, and a subscription’s first invoice and its renewals spend the balance the same way. A credit smaller than the invoice is measured; a larger credit, a positive balance and the subscription invoices follow the same rule, inferred. Theapplied_to_invoicecustomer balance transaction is not modelled, and voiding an invoice does not give the balance back.
Known issues.
- A PaymentIntent cancel accepts an unknown
cancellation_reason, where Stripe answers 400 - A test card can’t always be saved: a confirmed SetupIntent saves no card, and
pm_card_bypassPending,pm_card_pendingRefundandpm_card_createDisputecan’t be attached to a customer - A card that attaches but declines every charge declines only as
pm_card_chargeCustomerFail, and astok_chargeCustomerFailonPOST /v1/charges: a PaymentMethod made from that token or from its number 4000000000000341 charges successfully, where Stripe declines
Not served. 416 of the 612 operations in the published spec are not served.
- accounts, 1 of 29 served:
GET /v1/accounts,POST /v1/accounts,GET /v1/accounts/{account},POST /v1/accounts/{account},DELETE /v1/accounts/{account},POST /v1/accounts/{account}/bank_accounts,GET /v1/accounts/{account}/bank_accounts/{id},POST /v1/accounts/{account}/bank_accounts/{id}, … and 20 more - balance, 1 of 3 served:
GET /v1/balance/history,GET /v1/balance/history/{id} - charges, 12 of 14 served:
POST /v1/charges/{charge}/dispute,POST /v1/charges/{charge}/dispute/close - core, 15 of 25 served:
POST /v2/core/account_links,POST /v2/core/account_tokens,GET /v2/core/account_tokens/{id},POST /v2/core/accounts/{account_id}/person_tokens,GET /v2/core/accounts/{account_id}/person_tokens/{id},GET /v2/core/accounts/{account_id}/persons,POST /v2/core/accounts/{account_id}/persons,GET /v2/core/accounts/{account_id}/persons/{id}, … and 2 more - customers, 8 of 47 served:
GET /v1/customers/{customer}/balance_transactions,POST /v1/customers/{customer}/balance_transactions,GET /v1/customers/{customer}/balance_transactions/{transaction},POST /v1/customers/{customer}/balance_transactions/{transaction},GET /v1/customers/{customer}/bank_accounts,POST /v1/customers/{customer}/bank_accounts,GET /v1/customers/{customer}/bank_accounts/{id},POST /v1/customers/{customer}/bank_accounts/{id}, … and 31 more - disputes, 2 of 4 served:
POST /v1/disputes/{dispute},POST /v1/disputes/{dispute}/close - invoices, 12 of 18 served:
POST /v1/invoices/create_preview,POST /v1/invoices/{invoice}/attach_payment,POST /v1/invoices/{invoice}/lines/{line_item_id},POST /v1/invoices/{invoice}/remove_lines,POST /v1/invoices/{invoice}/send,POST /v1/invoices/{invoice}/update_lines - payouts, 5 of 6 served:
POST /v1/payouts/{payout}/reverse - radar, 9 of 12 served:
GET /v1/radar/early_fraud_warnings,GET /v1/radar/early_fraud_warnings/{early_fraud_warning},POST /v1/radar/payment_evaluations - test_helpers, 5 of 44 served:
POST /v1/test_helpers/confirmation_tokens,POST /v1/test_helpers/customers/{customer}/fund_cash_balance,POST /v1/test_helpers/issuing/authorizations,POST /v1/test_helpers/issuing/authorizations/{authorization}/capture,POST /v1/test_helpers/issuing/authorizations/{authorization}/expire,POST /v1/test_helpers/issuing/authorizations/{authorization}/finalize_amount,POST /v1/test_helpers/issuing/authorizations/{authorization}/fraud_challenges/respond,POST /v1/test_helpers/issuing/authorizations/{authorization}/increment, … and 31 more - Not served at all: account (1), account_links (1), account_sessions (1), apple_pay (4), application_fees (7), apps (4), climate (9), confirmation_tokens (1), country_specs (2), coupons (5), customer_sessions (1), entitlements (6), ephemeral_keys (2), exchange_rates (2), external_accounts (1), file_links (4), files (3), financial_connections (11), forwarding (3), identity (8), … and 27 more
Required fields the model does not declare. The spec requires these in a success response; the model’s response shape leaves them out, so the twin may not return them:
DELETE /v1/subscriptions/{subscription_exposed_id}(200):automatic_tax,billing_mode,billing_schedules,discounts,invoice_settingsDELETE /v1/subscriptions/{subscription_exposed_id}/discount(200):source,startGET /v1/charges/{charge}/dispute(200):evidence_details/enhanced_eligibilityGET /v1/invoices(200):data/*/amount_overpaid,data/*/amount_paid_off_stripe,data/*/amount_shipping,data/*/attempt_count,data/*/attempted,data/*/auto_advance,data/*/automatic_tax,data/*/default_tax_rates, … and 9 moreGET /v1/invoices/{invoice}(200):amount_paid_off_stripeGET /v1/payment_links(200):data/*/after_completion,data/*/automatic_tax,data/*/custom_fields,data/*/custom_text,data/*/phone_number_collection,data/*/shipping_options,data/*/tax_id_collectionGET /v1/subscriptions(200):data/*/automatic_tax,data/*/billing_mode,data/*/billing_schedules,data/*/discounts,data/*/invoice_settingsGET /v1/subscriptions/{subscription_exposed_id}(200):items/data/*/discounts,items/data/*/metadata,items/data/*/price/active,items/data/*/price/billing_scheme,items/data/*/price/livemode,items/data/*/price/metadata,items/data/*/price/typePOST /v1/invoices(200):amount_overpaid,amount_paid_off_stripe,attempt_count,attempted,auto_advance,automatic_tax,default_tax_rates,discounts, … and 8 morePOST /v1/invoices/{invoice}(200):default_tax_rates,discountsPOST /v1/invoices/{invoice}/add_lines(200):amount_overpaid,amount_paid_off_stripe,amount_shipping,attempt_count,attempted,auto_advance,automatic_tax,default_tax_rates, … and 13 morePOST /v1/invoices/{invoice}/finalize(200):amount_overpaid,amount_paid_off_stripe,amount_shipping,attempt_count,attempted,auto_advance,automatic_tax,created, … and 11 morePOST /v1/invoices/{invoice}/finalize(200):amount_overpaid,amount_paid_off_stripe,amount_shipping,attempt_count,attempted,auto_advance,automatic_tax,collection_method, … and 14 morePOST /v1/invoices/{invoice}/mark_uncollectible(200):amount_paid_off_stripe,attempt_count,attempted,auto_advance,automatic_tax,default_tax_rates,discounts,issuer, … and 4 morePOST /v1/invoices/{invoice}/pay(200):auto_advance,automatic_tax,default_tax_rates,discounts,issuer,payment_settings,period_end,period_start, … and 3 morePOST /v1/invoices/{invoice}/void(200):amount_overpaid,amount_paid_off_stripe,amount_shipping,attempt_count,attempted,auto_advance,automatic_tax,default_tax_rates, … and 8 morePOST /v1/payment_links/{payment_link}(200):after_completion,automatic_tax,custom_fields,custom_text,phone_number_collection,shipping_optionsPOST /v1/payouts(200):idPOST /v1/subscription_items/{item}(200):price/metadataPOST /v1/subscriptions(200):automatic_tax,billing_mode,billing_schedules,discounts,invoice_settings,items/data/*/discounts,items/data/*/metadata,items/data/*/price/billing_scheme, … and 4 more- … and 3 more
Not measured. The fidelity suite runs 1432 probes, and 43 documented branches have none. 6 need a credential the measuring account does not hold (200 ×3, 401 ×1, 403 ×2). The other 37:
| Operation | Status | Why |
|---|---|---|
DELETE /v1/products/{product}/features/{id} | 200 | the model describes no input that reaches it |
GET /v1/balance_transactions | 400 | the model describes no input that reaches it |
GET /v1/billing/credit_balance_summary | 200 | the model describes no input that reaches it |
GET /v1/billing/credit_balance_summary | 404 | the model describes no input that reaches it |
GET /v1/credit_notes/preview | 400 | nothing the twin serves creates the record it needs |
GET /v1/credit_notes/preview/lines | 400 | the model describes no input that reaches it |
GET /v1/events/{id} | 200 | nothing the twin serves creates the record it needs |
GET /v1/invoices/{invoice}/lines | 400 | nothing the twin serves creates the record it needs |
GET /v1/payment_intents/search | 200 | the model describes no input that reaches it |
GET /v1/prices/search | 200 | the model describes no input that reaches it |
GET /v1/products/search | 200 | the model describes no input that reaches it |
GET /v1/products/{product}/features | 200 | the model describes no input that reaches it |
GET /v1/products/{product}/features/{id} | 200 | the model describes no input that reaches it |
GET /v1/radar/value_list_items | 404 | the model describes no input that reaches it |
GET /v1/subscription_items | 400 | nothing the twin serves creates the record it needs |
GET /v1/subscription_items | 404 | the model describes no input that reaches it |
GET /v1/subscriptions/search | 200 | the model describes no input that reaches it |
POST /v1/charges/{charge}/refunds/{refund} | 200 | the model describes no input that reaches it |
POST /v1/charges/{charge}/refunds/{refund} | 400 | the model describes no input that reaches it |
POST /v1/charges/{charge}/refunds/{refund} | 404 | the model describes no input that reaches it |
| … and 14 more |
2 variant probes of branches the suite measures are not sent and are kept as unmeasured: GET /v1/billing/alerts (200), POST /v1/payouts (200).
Not modeled. What the model’s operations state they leave out:
- The guard order between the item’s existence check and the draft-attachment check is assumed (existence first) but not measured. (
DELETE /v1/invoiceitems/{invoiceitem}) - Whether the refusal for a non-draft attachment carries an error code (the spec’s default error response names no code for this route). (
DELETE /v1/invoiceitems/{invoiceitem}) - No captured trace of the non-draft refusal body; its status, code and message are inferred from the operation description and the sibling void refusal. (
DELETE /v1/invoices/{invoice}) - Reading the emitted v1.invoice.deleted event back through GET /v2/core/events?object_id=
is documented in the operation note (ev_op_delete_v1_invoices_invoice) but is not declared here: object_id is a parameter of the event-list route, not of this DELETE, which takes only the path parameter invoice. (DELETE /v1/invoices/{invoice}) - The subscription-invoice half of the draft guard: a draft invoice generated for a subscription is also refused, but no in-scope capture distinguishes its message or error code from the finalized-invoice refusal, so both land on br_400_invoice_not_draft. (
DELETE /v1/invoices/{invoice}) - The SKU restriction for type=good products: no SKU resource exists in this slice, so the branch where a product with SKUs is refused is not modeled. (
DELETE /v1/products/{id}) - The price-block guard reads the whole price collection because prices are keyed by their own id, not by product; no member-keyed absence predicate is expressible, so no machine-readable x_expects is declared. (
DELETE /v1/products/{id}) - Whether the 400 price-block error carries an error.code and doc_url is unmeasured; only type and message are asserted in the example. (
DELETE /v1/products/{id}) - Whether an unknown body parameter is rejected (additionalProperties false in the request schema) is declared from the spec but unmeasured on the live service. (
DELETE /v1/products/{product}/features/{id}) - Whether deleting an attachment whose product exists but whose id belongs to a different product answers 404 with param ‘id’ or param ‘product’ is unmeasured. (
DELETE /v1/products/{product}/features/{id}) - Stripe’s refusal when the item’s parent value list is referenced by an active Radar rule is documented on the value-list delete, not on this item delete; no evidence in this bundle states whether an item delete can be refused for that reason. (
DELETE /v1/radar/value_list_items/{item}) - The documented refusal ‘to be deleted, a value list must not be referenced in any rules’ is not modeled: Radar rules are outside this slice, so no state family holds rule references and no derivable recipe exists for that rejection branch. (
DELETE /v1/radar/value_lists/{value_list}) - Stripe refuses deleting the only remaining item of a subscription in practice, but the sliced spec states no such rule or error code; no branch is modeled until wire evidence pins the status/code. (
DELETE /v1/subscription_items/{item}) - clear_usage=true on a non-metered price is documented as disallowed but the refusal’s status and error code are unmeasured. (
DELETE /v1/subscription_items/{item}) - customer.subscription.updated for the parent and the credit’s invoiceitem.created are the subscription reducer’s to emit (asm_subscription_proration), not declared as outbound effects here. (
DELETE /v1/subscription_items/{item}) - proration_date and payment_behavior are not read, and always_invoice credits as create_prorations does, leaving the credit pending (not modelled: Stripe invoices it at once) (asm_subscription_proration). (
DELETE /v1/subscription_items/{item}) - Which non-active statuses (past_due, unpaid, paused, incomplete) the live service accepts for cancel is inferred from the subscription lifecycle, not stated by this operation. (
DELETE /v1/subscriptions/{subscription_exposed_id}) - No in-scope vector creates a Discount (coupons are out of the modelled slice), so the success branch is reachable only against a seeded state_discount record; the probe for br_200_deleted has no API-only producer recipe. (
DELETE /v1/subscriptions/{subscription_exposed_id}/discount) - Whether removing a subscription discount emits a customer.discount.deleted event is undocumented in this slice; no outbound effect is declared. (
DELETE /v1/subscriptions/{subscription_exposed_id}/discount) - What a clock delete does to the clock’s invoice items, credit grants and credit balance transactions is unmeasured and not modeled. Its customers are deleted and their subscriptions canceled (asm_test_clock_delete_cascades), and each cancel, its customer already deleted, marks the subscription’s live drafts uncollectible and stops the customer’s open invoices (asm_subscription_cancel_stops_collection; unmeasured for a clock delete). (
DELETE /v1/test_helpers/test_clocks/{test_clock}) - … and 508 more
Cookbook
Charge, refund, assert
curl -s localhost:8080/v1/charges -u sk_test_twin: \
-d amount=2000 -d currency=usd -d source=tok_visa
# -> {"amount":2000,...,"id":"ch_000000000000000000000001",...}
curl -s localhost:8080/v1/refunds -u sk_test_twin: -d charge=ch_000000000000000000000001
curl -s localhost:8080/v1/charges/ch_000000000000000000000001 -u sk_test_twin:
# -> "refunded": true, "amount_refunded": 2000
curl -s 'localhost:8080/_rystic/egress?tail=5'
# -> charge.succeeded, refund.created, charge.refunded, refund.updated, charge.refund.updated
Authorize now, capture later
curl -s localhost:8080/v1/payment_intents -u sk_test_twin: \
-d amount=5000 -d currency=usd -d 'payment_method_types[]=card' \
-d payment_method=pm_card_visa -d capture_method=manual -d confirm=true
# -> "id":"pi_000000000000000000000001", "status":"requires_capture"
curl -s -X POST localhost:8080/v1/payment_intents/pi_000000000000000000000001/capture -u sk_test_twin:
# -> "status":"succeeded", "amount_received":5000
Seed a returning customer
curl -X POST localhost:8080/_rystic/seed -d '{
"state_customer": { "cus_dev": { "id": "cus_dev", "object": "customer",
"email": "dev@example.com" } } }'
curl -s localhost:8080/v1/customers/cus_dev -u sk_test_twin:
# -> {...,"email":"dev@example.com","id":"cus_dev",...}
Deterministic ids in CI
curl -X POST localhost:8080/_rystic/reset
curl -X POST localhost:8080/_rystic/clock -d '{"time":"2026-07-23T00:00:00Z"}'
# ... run your payment flow ...
curl -s localhost:8080/_rystic/inspect > state.json
# diff state.json against the last green run: the same requests give the same bytes
Release notes: Changelog