Skip to Content
SimulatorsStripe changelog

Simulators

Stripe changelog

What changed in each Stripe simulator release, newest first.

v0.0.19 — 2026-11-05

  1. Long clock moves run faster and hold less memory. Each event a /_rystic/clock move emits is copied fewer times on its way into the event logs, so 1,000 subscriptions advance 24 months in about 16 seconds instead of 22, with about a tenth less memory at the end.

  2. Every body parameter the API reference documents for an endpoint is accepted. A request naming one the twin used to refuse with 400 parameter_unknown, such as preferred_locales[] on POST /v1/customers/{customer}, expand on an update, or the 58 payment method type hashes (acss_debit, affirm, …) of POST /v1/payment_methods, now goes through, and the twin ignores whatever it does not model. POST /v1/prices, POST /v1/subscriptions and POST /v1/payment_intents/{intent}/confirm still refuse some of theirs.

  3. A request with no API key is refused on every route. As Stripe does, the twin answers 401 You did not provide an API key. before it reads the path or body, in v2’s envelope (code api_keys_missing) on a /v2/ route; it used to serve such a request on every route but five lists. Send a placeholder sk_test_... key on every request.

  4. Every captured charge’s balance transaction resolves and expands. A card charge captured by a PaymentIntent’s capture (whole or in part), by POST /v1/charges or its capture route, by an invoice’s payment or by a subscription’s first invoice now writes the charge balance transaction its balance_transaction names, so GET /v1/balance_transactions/{id} answers it and GET /v1/balance_transactions?source=<charge> lists it, where the twin answered 404 and listed nothing. expand[]=balance_transaction on a charge embeds it, as Stripe does; the twin refused that expansion before.

  5. A value outside a parameter’s enum is refused. As Stripe does, the twin answers 400 invalid_request_error naming the parameter (Invalid tax_exempt: must be one of exempt, none, or reverse) before it stores or acts on anything, and v2’s invalid_fields envelope on a /v2/ route; it used to store tax_exempt=bogus or refund with reason=because. The message lists the values in the spec’s order, which can differ from Stripe’s. An empty value still reads as an attempt to unset the parameter, and origin on a refund create is now refused as an unknown parameter, as Stripe refuses it.

  6. An invoice carries the fields Stripe sends, and no others. Every invoice the twin answers (a retrieve, a list or search row, a subscription’s embedded latest_invoice and an invoice event’s object) now carries the 74 fields Stripe returns, among them account_country, account_name, customer_email, customer_name, customer_address, customer_tax_exempt, total_discount_amounts and total_pretax_credit_amounts, 22 of which a subscription’s invoice lacked. It no longer carries a top-level subscription (read parent.subscription_details.subscription, as on Stripe), amount_paid_off_stripe, or, outside expand[]=payments, payments. webhooks_delivered_at reads the invoice’s created, where a subscription’s invoice read null and a finalized one-off invoice read its finalize time.

  7. A PaymentIntent create or confirm refuses a payment method it can’t find, and the fraud test cards decline. POST /v1/payment_intents and POST /v1/payment_intents/{intent}/confirm naming a payment method that is neither a stored PaymentMethod nor a test card the twin serves, such as pm_card_doesNotExist or pm_doesNotExist, now answer Stripe’s 400 resource_missing (“No such PaymentMethod: ’…’”) and write nothing; the confirm’s refusal carries the unchanged intent. The twin used to accept any such id: a confirming create or a confirm answered succeeded. A Stripe test card the twin doesn’t serve, such as pm_card_ca, is refused the same way, as attach and customer create already refuse it; the HOWTO lists the cards it serves. pm_card_chargeDeclinedFraudulent, pm_card_visa_chargeDeclinedFraudulent and pm_card_radarBlock now decline with card_declined and decline_code fraudulent at create with confirm=true and on confirm, where they succeeded.

  8. The twin documents its own control plane in Swagger. /_rystic/swagger now lists a Rystic control plane spec, served at /_rystic/swagger/spec/rystic-control-plane.json, describing the /_rystic/* routes this twin serves (its scenarios, inspect views and seedable registers by name) and its /_dispute route for raising a dispute on a charge.

  9. A state dump seeds back as it was, and the next record gets a fresh id. GET /_rystic/inspect posted to POST /_rystic/seed after a reset restores every record where it was, per-caller records included, which used to land under a doubled key. The next record created after any seed passes every uuid and event id the simulator minted that the seed holds. GET /_rystic/inspect?view=sequences lists every id counter, and a world document’s new sequences key restores them, so ids the simulator renders from a counter continue where the dump left off.

  10. Coupons, promotion codes and invoice previews. The simulator serves /v1/coupons (create, retrieve, update, delete, list), /v1/promotion_codes (create, retrieve, update, list) and POST /v1/invoices/create_preview, and /_rystic/seed takes state_coupon and state_promotion_code records.

  11. A slow client no longer holds up the others. The simulator reads a request’s whole body before it starts serving it and sends the answer after, so a client that uploads slowly, or reads a large answer slowly, no longer delays other clients’ requests. Answers are unchanged; one larger than 4 MiB still makes other requests wait while it is sent.

  12. A one-time tiered price is refused, and invoices redeem their discounts. Creating a tiered price without recurring answers 400 invalid_request_error, param interval, “Prices with type=one_time are not supported with tiered billing.”, as Stripe does. Creating an invoice or invoice item whose discounts name a promotion code or coupon redeems it: its times_redeemed, and the promoted coupon’s, grow by one, and once max_redemptions is used the code reads active: false and the coupon valid: false. A later invoice, invoice item or preview that offers a used-up, inactive or unknown code or coupon is refused, and nothing is redeemed.

  13. An invoice preview prices packs, sub-cent prices and line discounts, and the price list matches Stripe. POST /v1/invoices/create_preview bills a price with transform_quantity by whole packs, bills a unit_amount_decimal price at its exact decimal rounded half up once per line, and applies a line’s own discounts to that line before the subtotal. GET /v1/prices without an active filter lists archived prices too. A price’s currency_options appears only when the request expands it, and then holds the price’s own currency beside the others.

  14. The call log shows what each call asked for and whether it succeeded. Each GET /_rystic/calls entry now carries args, the call’s query and body arguments with credentials redacted (long values cut, file contents left out), and ok, false for an answer that is not 2xx. A path the simulator does not serve is marked unknown_route: true. ?tail=N returns only the last N calls, and dropped counts the calls the log no longer keeps.

  15. The docs are one page, with a page per feature. The archive ships README.md in place of HOWTO.md, with the same sections in the same order as every Rystic simulator’s page, and CHANGELOG.md beside it. Subscriptions and invoices, test clocks, Connect, test cards and disputes, bank debits, the balance, the hosted pages and the Dashboard each have their own page under docs/features/, and every example on these pages runs as written against a fresh simulator.

  16. The docs page links the admin token section. The page now sends you to the admin token’s section of the shared-simulators page.

  17. Scenario descriptions use customer terms. The Scenarios table and --list-scenarios describe each scenario in American spelling, with no internal references.

  18. Placeholders in the docs show on the site. A placeholder such as <id> in the docs now shows on the site, where it was left out.

  19. Draft invoice discounts carry their amounts and expand into Discount objects. An invoice created with discounts answers them as di_ ids, and expand[]=discounts or expand[]=lines.data.discounts on an invoice retrieve (data.discounts on a list) answers each as a Discount object naming its promotion_code and source.coupon. On a draft, an invoice-level discount covers only the discountable lines, a fixed amount is capped at what those lines cost, and each line’s discount_amounts and the invoice’s total_discount_amounts list every discount’s share, recomputed whenever an invoice item is created, updated or deleted, lines are added, pending items are included at creation or the invoice’s discounts change. A draft lists its invoice item lines newest first. Reusing a discount id that is not the customer’s own, or naming one that does not exist, is refused, as Stripe does.

  20. The Scenarios table is easier to scan. Each row says in a line what the scenario is for, and a list below the table says what each scenario holds.

  21. Pro starts with a 14-day trial. Free has ended, so every simulator is on Pro and Enterprise, and the docs name only those plans.

v0.0.18 — 2026-10-20

  1. A customer base moves through two years in seconds. A /_rystic/clock move settles each stop’s subscription renewals, trial ends, invoice finalizations and payment retries in one pass, so 1,000 subscriptions advance 24 months in about 30 seconds instead of several minutes, and reads after the move stay as fast as on an empty world. A world whose subscriptions all fall due at different instants still slows as it grows.

  2. A subscription’s automatically paid invoices send invoice_payment.paid. A create that pays its first invoice from a card now sends invoice_payment.paid between invoice.created and invoice.paid, a renewal or trial-end draft the clock finalizes and pays sends it after invoice.payment_succeeded, and a retried payment that succeeds, an update’s trial_end=now and an always_invoice item add that pay from the card send it too, each carrying the paid invoice payment (status paid, invoice the invoice’s ID, amount_paid the amount, payment naming the PaymentIntent) as Stripe does. An invoice paid at zero, a trial’s or one a credit covers, has no invoice payment and sends none. Before, only an invoice pay and a PaymentIntent confirm sent it, so a billing flow’s events missed one type per automatic payment.

  3. A subscription’s renewal is announced seven days ahead. invoice.upcoming now goes out seven days before every renewal’s period end, naming no request and previewing the renewal invoice with its lines, amount and period; before, it went out only before a trial’s end. The lead is an account setting, “upcoming renewal events”, which the twin does not model and fixes at the measured account’s seven days. A subscription set to cancel at its period end gets none, nor does a period shorter than seven days. A send_invoice subscription’s renewal and trial-end drafts, the open invoices they become, its invoice.upcoming previews and a cancel’s final invoice now read next_payment_attempt null, as Stripe documents for every send_invoice invoice; before, the drafts, their open invoices and the trial-end preview carried the finalize hour.

v0.0.17 — 2026-10-20

  1. Requests stay fast as subscriptions accumulate. A request’s cost no longer grows with the number of subscriptions in the world: a GET on a world of 1,000 subscriptions answers as fast as on an empty one, and creating 1,000 subscriptions and moving /_rystic/clock through three months takes seconds instead of minutes.

  2. pm_card_chargeCustomerFail is declined on a PaymentIntent, an invoice pay and a subscription create too. A PaymentIntent created with confirm=true or confirmed with the card, and an invoice pay charging it, now answer Stripe’s 402 card_error (card_declined, generic_decline) where they used to succeed, leaving a failed charge and the PaymentIntent requires_payment_method, sent as charge.failed and payment_intent.payment_failed; the invoice a pay names stays open with one attempt and sends invoice.payment_failed. A subscription create charging the card reads incomplete with its first invoice open, and under payment_behavior=error_if_incomplete it answers the 402, creates no subscription or invoice and cancels its PaymentIntent. A subscription update’s trial_end=now charging the card is declined the same way, its invoice left open.

  3. A JCB charge reads electronic_commerce_indicator null. POST /v1/charges with a JCB test card answers payment_method_details.card.electronic_commerce_indicator null, as a PaymentIntent’s charge already did, where it used to read 07.

  4. A refund made through POST /v1/charges/{charge}/refund sends four more refund members in its events. Its refund.created, refund.updated and charge.refund.updated objects carry customer_account, receipt_number, source_transfer_reversal and transfer_reversal as null, as Stripe sends them and as a read of the refund already answered, where the events used to leave them out.

  5. Both charge refund routes write the refund’s balance transaction. A refund of a captured charge through POST /v1/charges/{charge}/refunds or the legacy POST /v1/charges/{charge}/refund names its balance transaction as one through POST /v1/refunds does: GET /v1/balance_transactions/{id} reads it as type refund, pending, the refunded amount negated with no fee, and GET /v1/balance_transactions?source=<refund> lists it, a partial refund and the refund of the rest each their own. The first route named an ID no balance transaction answered, and the legacy route named none. A release of an uncaptured charge still writes none, and so does a legacy refund of a charge whose own balance transaction has not attached yet, which is succeeded at once as Stripe’s is.

  6. A Checkout Session create refuses an expires_at that is not an integer. POST /v1/checkout/sessions answers an expires_at such as abc, 1e9, 1800.0, an empty value or one with a leading space with Stripe’s 400 parameter_invalid_integer, naming param expires_at and quoting the value as sent, and creates nothing, where it used to ignore it and apply the 24-hour default. A seeded session whose expires_at is missing, or not a timestamp, now reads, lists and expires a day after its created, where each of those used to read it differently.

  7. A credit note create refuses an effective_at Stripe refuses. POST /v1/credit_notes answers an effective_at that is not an integer, an empty value or one with a leading space included, with Stripe’s 400 parameter_invalid_integer, one before the invoice was finalized with Specified effective date must be after the associated invoice's `finalized_at`., and one after the clock with Specified effective date must be within past 5 years., as the preview does, and writes no credit note, where it used to create one with any value it could read. A create with no invoice still answers parameter_missing first.

  8. A credit note preview refuses an empty invoice, and a missing invoice is refused before effective_at. GET /v1/credit_notes/preview answers invoice= with Stripe’s 400 parameter_invalid_empty on invoice, where it used to answer parameter_missing. A preview or a create naming an invoice that does not exist answers resource_missing on invoice whatever its effective_at, as Stripe’s preview does, where an effective_at that is not an integer or in the future used to be refused first.

  9. A credit note preview refuses an effective_at Stripe refuses. GET /v1/credit_notes/preview answers an effective_at that is not an integer, an empty value or one with a leading space included, with Stripe’s 400 parameter_invalid_integer, one before the invoice was finalized, however far back, with Specified effective date must be after the associated invoice's `finalized_at`., and one after the clock, however far ahead, with Specified effective date must be within past 5 years., where it used to preview any value it could read. A preview with no invoice still answers parameter_missing first.

  10. A credit note’s refund names a balance transaction you can read. A credit note with a refund_amount now writes the balance transaction its refund names, so GET /v1/balance_transactions/{id} and ?source=<refund> answer it: type refund, pending, the refunded amount negated with no fee, and described REFUND FOR CHARGE (Payment for Invoice) for an invoice payment’s charge, as Stripe’s is. The refund reads reason null, as Stripe’s does, and its acquirer reference no longer repeats one a refund made through another route holds.

  11. A customer’s balance is applied to its next invoice. A draft invoice now reads starting_balance as its customer’s balance and amount_due reduced by it, never below 0: after a -500 credit, a draft with a 1200 item reads amount_due 700 with total 1200 and ending_balance null. Finalizing reads the balance again, sets ending_balance (0 once a credit is spent), asks the PaymentIntent for the reduced amount, moves the customer’s balance to the invoice’s ending_balance and sends customer.updated before the finalize’s invoice.updated, as Stripe does. A credit that covers the whole invoice leaves it paid at the finalize with no PaymentIntent, and a positive balance is added to the amount due. A subscription’s first invoice and its renewals spend the balance the same way, whether a customer update, a credit note or an earlier invoice left it. Before, every invoice read starting_balance 0 and the balance was never spent. The applied_to_invoice customer balance transaction is not modeled.

  12. A customer created with payment_method has that card attached. POST /v1/customers with payment_method=pm_card_visa, or a stored pm_ ID, attaches the PaymentMethod to the new customer as POST /v1/payment_methods/{id}/attach does: it lists under GET /v1/customers/{id}/payment_methods and GET /v1/payment_methods?customer=…&type=card, invoice_settings[default_payment_method] naming the same card stores the attached card’s ID, and the create sends payment_method.attached then customer.created. An unknown payment_method answers Stripe’s 400 resource_missing on that parameter and creates no customer; an empty one answers 400 parameter_invalid_empty and a hash or an array 400 “Invalid string: …”, as the same forms of test_clock do (inferred from those, unmeasured); a declining test token such as pm_card_chargeDeclined answers the attach route’s 402 card_error, and a detached card or one another customer holds its 400 invalid_request_error, each creating nothing (inferred from the attach route). Before, the parameter was dropped silently and the customer had no card, so Stripe’s test-clock recipe stopped at its third step.

  13. A customer create refuses an empty or non-string test_clock. POST /v1/customers with test_clock= answers Stripe’s 400 parameter_invalid_empty on test_clock, and one with a hash such as test_clock[x]=y answers 400 “Invalid string: {“x”:“y”}” on test_clock; neither creates a customer. Before, both created one.

  14. Deleting a customer leaves its subscriptions’ draft invoices uncollectible. DELETE /v1/customers/{customer} now marks each draft invoice of the subscriptions it cancels uncollectible, never finalized or charged, sending invoice.updated and invoice.marked_uncollectible before each customer.subscription.deleted, as Stripe does; its pending invoice items stay pending. Before, the clock finalized such a draft an hour later and could charge the card the delete had detached. A void of such an invoice, of any draft, or of an invoice already void, now answers Stripe’s message “You can only pass in open invoices. This invoice isn’t open.”, and a void of an invoice paid with money answers Stripe’s “Invoices with paid payments cannot be voided.”

  15. Paying an incomplete subscription’s invoice makes it active, and a declined trial_end=now leaves it past due. Paying the open first invoice of an incomplete subscription, with POST /v1/invoices/{invoice}/pay or by confirming its PaymentIntent, now makes the subscription active at once, as Stripe does, and sends invoice_payment.paid and customer.subscription.updated. A card pay’s events now run charge.succeeded, payment_intent.succeeded, invoice_payment.paid, invoice.updated, invoice.paid, invoice.payment_succeeded, and a pay after a failed attempt no longer counts another attempt. An update’s trial_end=now whose charge is declined now leaves the subscription past_due instead of active. A declined confirm stores the PaymentMethod it names without attaching it, payment_method_configuration_details names the account’s configuration whenever automatic_payment_methods is set, and POST /v1/charges declines tok_chargeCustomerFail and refuses a pm_ source with Stripe’s 400.

  16. A void or uncollectible mark of an invoice that is not open answers Stripe’s not-open message. POST /v1/invoices/{invoice}/mark_uncollectible of an invoice that is not open now answers Stripe’s 400 “You can only pass in open invoices. This invoice isn’t open.” A void of a paid invoice whose amount_paid is 0, such as a trial’s 0.00 first invoice, answers the same message; only an invoice paid with money, by card or out of band, gets “Invoices with paid payments cannot be voided.” Before, mark_uncollectible answered a message Stripe never sends, and a void of a paid 0.00 invoice answered the paid-invoice one.

  17. An out-of-band pay records its own invoice payment, and a paused update checks its booleans first. After POST /v1/invoices/{invoice}/pay with paid_out_of_band=true, GET /v1/invoice_payments?invoice= lists a new paid invoice payment of type payment_record, naming a pr_test_ payment record and no PaymentIntent, and the finalize’s invoice payment reads canceled, still naming its PaymentIntent. Neither is the default. Before, the finalize’s stayed open and the default, and nothing else was written. POST /v1/subscriptions/{id} with a cancel_at_period_end that is neither true nor false answers Stripe’s 400 “Invalid boolean: <value>” on param cancel_at_period_end, even on a paused subscription, where it used to answer the paused refusal. With billing_cycle_anchor=now beside cancel_at_period_end, a paused subscription answers the anchor’s refusal.

  18. Paying an invoice out of band attempts nothing and cancels its PaymentIntent. POST /v1/invoices/{invoice}/pay with paid_out_of_band=true leaves attempted and attempt_count as they were and cancels the PaymentIntent the finalize created, with cancellation_reason duplicate. It sends invoice.updated, invoice.paid and payment_intent.canceled, in that order, and no invoice.payment_succeeded. Every pay’s invoice.updated now names amount_remaining in previous_attributes, and a card payment’s names attempt_count too. Before, an out-of-band pay counted as an attempt, sent invoice.payment_succeeded and left the PaymentIntent waiting for a payment method.

  19. Microdeposit verification takes Stripe’s test values in any case, refuses neither or both inputs and fails a SetupIntent, and a verified ACH payment carries Stripe’s fee. POST /v1/payment_intents/{intent}/verify_microdeposits and POST /v1/setup_intents/{intent}/verify_microdeposits match the test codes in any case and the amounts in either order, and amounts 10 and 11 exceed the attempts and 40 and 41 time out, as SM33CC and SM44DD do. A verify sending neither input answers Stripe’s 400 parameter_missing on descriptor_code, and one sending both 400 parameters_exclusive. On a SetupIntent, SM33CC answers the attempts-exceeded 400 and SM44DD 200 with the SetupIntent requires_payment_method and last_setup_error setup_intent_setup_attempt_expired, each sending setup_intent.setup_failed and leaving the PaymentMethod unattached, and a wrong code answers the mismatch 400 with the attempts left, ten per SetupIntent. A verified us_bank_account payment’s balance transaction reads type payment with Stripe’s 0.8% ACH fee (8 on 10.00), available 5 s after it is created, and its net lands in the balance’s available. Before, a PaymentIntent verify matched the codes only in capitals, a SetupIntent’s wrong or failing verify answered 400 payment_method_microdeposit_verification_amounts_invalid, and the ACH balance transaction read type charge with the card fee, pending, and the balance took the card fee’s net into pending.

  20. A seller frozen before its activation activates after a re-request. A connected seller whose merchant configuration is deactivated and re-applied before card_payments ever activated reads card_payments null until it re-requests it; the re-request reads card_payments pending again, with stripe_balance.payouts pending when the seller holds a bank account, and both read active once /_rystic/clock is 85 seconds past the seller’s created, so the seller charges. The twin used to leave the re-requested card_payments pending and refuse the charge.

  21. A pending refund settles when the clock reaches it. A refund of a captured pm_card_pendingRefund / tok_pendingRefund charge, through POST /v1/refunds, either charge refund route or a credit note’s refund_amount, answers pending with pending_reason insufficient_funds and a card destination that names no acquirer reference yet, as Stripe’s does, and its pending balance transaction can be retrieved at once. Once /_rystic/clock reaches 122 seconds after the refund’s created it reads succeeded without pending_reason, and at 249 seconds its acquirer reference lands; each move sends refund.updated and charge.refund.updated naming no request, stamped at that instant even when one clock move jumps past it, and a connected account’s refund sends them as that account’s, listed by its GET /v1/events and not the platform’s. It no longer reads pending forever. Every refund read and update, the charge’s refund list included, answers pending_reason while the refund is pending, and the charge’s refund list now answers each refund’s balance_transaction and destination_details as the other refund reads do.

  22. Products and prices announce their creates, updates and deletes. POST /v1/products sends product.created, carrying the product as the request answers it, and a create with default_price_data now mints that price (named in default_price) and sends plan.created (for a recurring price) and price.created for it first. POST /v1/prices sends plan.created for a recurring price, carrying the legacy Plan view of the price under the same ID, then price.created; a one-time price sends price.created alone. So a catalog synced from webhooks hears about new products and prices. POST /v1/products/{id} sends product.updated and POST /v1/prices/{price} sends plan.updated (for a recurring price) then price.updated, each with previous_attributes over the fields the update changed, and DELETE /v1/products/{id} sends product.deleted. GET /v2/core/events?object_id= lists them as thin events. Not yet modeled: Stripe’s refusals of a malformed default_price_data, and the product.created a price create with product_data sends. Before, none of these routes wrote an event, and default_price_data was ignored.

  23. A sepa_debit payment processes, then settles on the clock, and a us_bank_account one waits on microdeposits. POST /v1/payment_methods takes type=sepa_debit with an iban. A PaymentIntent created with confirm=true and a sepa_debit method answers processing with a pending charge, and 15 s after the charge the clock settles it, sending charge.updated, payment_intent.succeeded and charge.succeeded as Stripe does, or, for the failing test IBAN DE62370400440532013001, returning the intent to requires_payment_method with payment_intent.payment_failed, charge.failed and charge.updated; a payment made acting as a connected account settles in that account, and its settle events are the account’s. A us_bank_account create-and-confirm without Financial Connections answers requires_action with verify_with_microdeposits, and a sepa_debit SetupIntent created with confirm=true succeeds at once; before, the PaymentMethod create refused sepa_debit and each of these creates answered requires_payment_method.

  24. billing_cycle_anchor=now restarts the billing period. An update with billing_cycle_anchor=now on an active subscription now restarts each item’s period at the update, writes no invoice and, unless proration_behavior is none, leaves one pending proration for the days the new period adds past the old one, which the next renewal bills first, as Stripe does. Before, it moved the anchor and left the current period running.

  25. A trial with no payment method mints a pending SetupIntent. A charge_automatically trialing POST /v1/subscriptions whose customer has no default payment method now names a SetupIntent in pending_setup_intent, requires_payment_method with usage off_session, and sends setup_intent.created before its other events; a customer with no currency takes the subscription’s, with customer.updated. A send_invoice trial, which nobody has measured, keeps pending_setup_intent null. A create judges trial_end on its customer’s test clock, as it stamps the subscription: one at or before that clock answers Stripe’s 400 with param trial_end, and trial_end beside trial_period_days answers 400 “Received both trial_end and trial_period_days parameters. Please pass in only one.” Before, pending_setup_intent was null, a past trial_end started no trial, and the payment-method check read the twin’s clock.

  26. The clock’s payment_intent.created carries the PaymentIntent as created. When the clock finalizes a subscription invoice and charges it, payment_intent.created reads the PaymentIntent before the charge, requires_payment_method with amount_received 0 and no payment_method, latest_charge or last_payment_error, as Stripe sends it, where it used to carry the PaymentIntent after the charge.

  27. A subscription create refuses an empty collection_method and takes the cancel_at literals. POST /v1/subscriptions with collection_method empty answers Stripe’s 400 parameter_invalid_empty naming collection_method, before every other check, and writes nothing. Before, it read as omitted and created the subscription. A cancel_at of min_period_end, max_period_end or max_billed_until sets cancel_at to the first period’s end, records the cancellation request and bills that period whole, as Stripe does.

  28. A subscription create refuses a malformed collection_method, trial_end or cancel_at. Before it looks anything up, POST /v1/subscriptions answers Stripe’s 400 naming the parameter, checked in this order: a non-empty collection_method other than charge_automatically or send_invoice (“Invalid collection_method: must be one of send_invoice or charge_automatically”), a trial_end that is neither now nor a unix time, such as abc, 1.5 or an RFC 3339 time (“Invalid trial_end: must be one of now”), and a cancel_at that is neither a unix time nor min_period_end, max_period_end or max_billed_until (“Invalid cancel_at: must be one of min_period_end, max_period_end, or max_billed_until”); an empty trial_end or cancel_at answers parameter_invalid_empty, an empty cancel_at even beside cancel_at_period_end=true. Before, these creates answered 200, ignoring the value or reading an RFC 3339 time as a timestamp.

  29. A subscription create takes cancel_at. POST /v1/subscriptions now accepts cancel_at, recording a cancellation request: canceled_at is the create’s time and cancellation_details.reason is cancellation_requested. A cancel_at before the first period’s end ends that period there, billed on the first invoice as a proration with its own invoice item; under a trial, trial_end and the anchor stay and the zero line runs to cancel_at. The clock then cancels the subscription at cancel_at with no further invoice. A cancel_at at or before the create’s clock (a test clock’s frozen_time when the customer is on one) answers Stripe’s 400 “The cancel_at param can only be set to a date in the future.”, and one beside cancel_at_period_end=true answers 400 “Received both cancel_at_period_end and cancel_at parameters. Please pass in only one.” Before, the create refused cancel_at with parameter_unknown.

  30. pm_card_chargeCustomerFail attaches, and a subscription charging it on the clock goes past_due. Attaching the test card that Stripe attaches but declines at every charge answers 200 with a Visa ending in 0341, where it used to answer 404. When the clock finalizes a subscription invoice that charges it, at a trial’s end or a renewal, the charge is declined: it reads failed with card_declined, the PaymentIntent reads requires_payment_method with the decline as its last_payment_error, charge.failed and payment_intent.payment_failed go out, and the invoice stays open with one attempt and its retry dated, the subscription reads past_due and the customer delinquent; each retry the clock runs on that card is declined the same way. Paying the invoice with another card succeeds its PaymentIntent and clears that last_payment_error.

  31. A failed renewal is retried, closing or paying its invoice ends past_due, and item changes bill or credit as Stripe does. When a renewal’s payment fails, the open invoice’s next_payment_attempt reads 42.1 h after the attempt, which puts the ninth attempt where Stripe’s fell, and a second invoice.updated announces it before invoice.payment_failed. The clock then runs that retry: with no card it fails again, attempt_count 2 and the next retry 42.1 h on, and the ninth attempt cancels the subscription for payment_failed, leaving the invoice open and the customer delinquent; with a default card that charges it pays the invoice, customer.updated last. Voiding the invoice, marking it uncollectible or paying it returns the past_due subscription to active in that request, and each clears the customer’s delinquent with customer.updated, even while another of its subscriptions is still past_due; an invoice paid any other way does the same at the clock’s next settle. Ending a subscription, by DELETE, at its cancel_at or by deleting its customer, stops automatic collection of the customer’s open invoices and of the subscription’s renewal draft: each reads auto_advance false and no retry, with invoice.updated naming no request, and the draft never finalizes; deleting the customer or its test clock marks the draft uncollectible instead. DELETE with invoice_now=true&prorate=true, and a cancel_at set inside the period, which cuts the period there, credit the unused time on a final 0.00 invoice paid an hour later onto the customer’s balance, clearing delinquent. A trial shorter than three days sends no invoice.upcoming. A paid invoice reads auto_advance false and no retry and keeps its attempt_count, and its invoice.updated holds Stripe’s six changes; the pay’s events follow Stripe’s order, customer.updated first and customer.subscription.updated last. POST /v1/subscription_items under proration_behavior=always_invoice bills the added item’s proration at once on a subscription_update invoice: paid from the card under charge_automatically, paid with no payment when it is 0, and a draft that finalizes an hour later under send_invoice; under charge_automatically with something to bill and no stored payment method the add is refused with Stripe’s 400 resource_missing. Deleting an item mid-period leaves a pending credit for its unused time, figured from what was last billed for it (339 for 21 days credits -178 for the last 11), keeps any pending proration it had, and sends invoiceitem.created and customer.subscription.updated. Proration descriptions write a one-digit day as 07 Nov 2026. Before, next_payment_attempt read null and nothing retried, only a paid invoice ended past_due and only on the next request, retries never ended, a cancel left its customer’s invoices and its renewal draft with auto_advance true, invoice_now and prorate billed nothing, always_invoice waited for the renewal, and a deleted item dropped its pending proration, credited nothing and sent no event.

  32. expand[]=latest_invoice on a subscription embeds the invoice. POST /v1/subscriptions, POST /v1/subscriptions/{id}, GET /v1/subscriptions/{id} and GET /v1/subscriptions now embed latest_invoice, customer, default_payment_method and latest_invoice.customer (with data. on the list) under expand[] or expand[0], as Stripe does; items and plan come back embedded either way. A path the subscription cannot expand, such as latest_invoice.nonexistent, answers Stripe’s 400 This property cannot be expanded, and a list path without data. answers the 400 that suggests it. A refused create stores nothing. Before, these routes ignored expand and answered the invoice ID.

  33. A subscription’s payment_intent.created carries the PaymentIntent as it was created. When a subscription create pays its first invoice, or an update’s trial_end=now pays the new period, payment_intent.created reads the PaymentIntent requires_payment_method with amount_received 0 and a null payment_method, latest_charge and last_payment_error, as Stripe sends it before the charge. Before, it read the PaymentIntent already succeeded.

  34. An unpaid resume invoice is voided when its pending update expires, and customer.subscription.resumed reads as Stripe’s does. Once the clock passes pending_update.expires_at, 23 hours after a resume with billing_cycle_anchor=now, the open resume invoice is voided, its PaymentIntent canceled and pending_update cleared, and the subscription stays paused, sending customer.subscription.pending_update_expired; a DELETE of the subscription before then voids it the same way first. When the resume invoice is paid, customer.subscription.resumed now carries the subscription as it stood before the pending update applied, trialing, as Stripe’s does, where it read active. Before, the invoice stayed open and a later payment still resumed the subscription.

  35. A connected account’s subscription renews as that account. A subscription created acting as a connected account (Stripe-Account or Stripe-Context) settles on the clock as that account: the PaymentIntent and charge paying its renewal or trial-end invoice are listed by the account’s GET /v1/payment_intents and GET /v1/charges, not the platform’s, the payment credits the account’s balance, and every event its clock sends (the renewal, the trial warning and end, a failed payment, the return to active once its invoice is paid, the resume, a resume’s expiry and the cancel at cancel_at) is the account’s, as an event its own requests cause is. Before, the renewal’s payment and events went to the platform.

  36. A trial’s end bills only up to cancel_at. When a trial ends on the clock and the subscription’s cancel_at falls inside the period that starts there, each item’s period now ends at cancel_at and the trial end’s invoice bills one Time on proration for those days, as Stripe does and as a renewal already did. Before, the trial’s end billed the whole period although the subscription was canceled at cancel_at a few days later.

  37. A subscription update refuses a malformed trial_end and an empty item ID. POST /v1/subscriptions/{id} with a trial_end that is neither now nor a unix time, such as abc, 1.5 or an RFC 3339 time, answers Stripe’s 400 “Invalid trial_end: must be one of now” on param trial_end. A hash or an array gets the same answer, though Stripe’s own answer to one is unmeasured. An update with items[i][id] empty answers 400 parameter_invalid_empty naming items[i][id]. Neither changes the subscription. Before, both answered 200.

  38. A subscription update refuses a past cancel_at, and cancel_at_period_end or billing_cycle_anchor=now on a paused subscription. POST /v1/subscriptions/{id} with a cancel_at at or before the subscription’s clock, a test clock’s frozen_time when it is on one, answers Stripe’s 400 “The cancel_at param can only be set to a date in the future.” On a paused subscription, cancel_at_period_end, true or false, and billing_cycle_anchor=now each answer Stripe’s 400 asking you to resume it first, writing nothing and sending no event. Before, each was accepted, and a past cancel_at or cancel_at_period_end=true had the clock cancel the subscription at its next move.

  39. A test clock advance checks its time as Stripe does. POST /v1/test_helpers/test_clocks/{id}/advance with a frozen_time before 2000-01-01 or from 2100-01-01 answers Stripe’s 400 “The provided timestamp <t> is invalid. …”; one before the clock’s time answers “Test clocks can only be advanced forward in time. Requested time <target> is before test clock time <frozen_time>.”; the clock’s own time is accepted and completes there; and one past the cap answers Stripe’s message naming it. The cap is two of the shortest recurring interval of the live subscriptions on the clock (a canceled or incomplete_expired one counts for nothing), two calendar months for a monthly one, and six years on a clock holding none; the cap itself is accepted. Before, the twin refused the clock’s own time and anything past two years with “frozen_time must be after the test clock’s current frozen time.”

  40. A test clock advance refuses a time that is not an integer as sent. POST /v1/test_helpers/test_clocks/{id}/advance with a frozen_time given as an RFC 3339 time, a decimal such as 1.5, a space-prefixed or empty value, an array (frozen_time[]) or a hash (frozen_time[a]) answers Stripe’s 400 parameter_invalid_integer on frozen_time, “Invalid integer: <value>”, the value echoed as Stripe renders it, and leaves the clock as it was; +<t> advances to <t>. A clock still advancing answers its 429 before the time is read, whatever the advance sends. Before, the twin advanced to an RFC 3339 or a space-prefixed time and refused a decimal or an empty value as a time not after the clock’s.

  41. A test clock create checks its frozen_time as Stripe does. POST /v1/test_helpers/test_clocks with a frozen_time that is not an integer as sent, such as abc, 1.5, an RFC 3339 time, a space-prefixed or empty value, an array or a hash, answers 400 parameter_invalid_integer on frozen_time, the value echoed as Stripe renders it ({:a=>"<t>"} for frozen_time[a]); a negative one answers 400 “Invalid timestamp: must be an integer”; and one before 2000-01-01 or from 2100-01-01 answers Stripe’s 400 range refusal. +<t> and 0<t> create at <t>. Before, the twin created a clock at an RFC 3339 time or in any year and answered a decimal as a missing parameter.

  42. Test clocks announce their create, advance and completion. POST /v1/test_helpers/test_clocks sends test_helpers.test_clock.created, carrying the clock as created. An advance sends test_helpers.test_clock.advancing at its request, carrying the clock as the advance answers it, and once it completes, every event the completion sends, then test_helpers.test_clock.ready, carrying the clock ready at the new time and naming no request, as Stripe orders them.

  43. Deleting a test clock sends test_helpers.test_clock.deleted. DELETE /v1/test_helpers/test_clocks/{id} sends the event, carrying the clock as it stood, after the customer.deleted of each customer on the clock, oldest first, and before the customer.subscription.deleted of each subscription it cancels, newest first, as Stripe orders them.

  44. A missing test clock answers Stripe’s message. A read, an advance or a delete of a test clock that never existed, or of one already deleted, answers 404 resource_missing with Stripe’s message, “No such billingclock: ‘<id>’”, where it said “No such test clock”.

  45. A verified us_bank_account payment settles on the clock, and the microdeposit verify answers Stripe’s test values. POST /v1/payment_intents/{intent}/verify_microdeposits with descriptor_code=SM11AA, or amounts 32 and 45, answers the intent processing with a pending charge, sending payment_intent.processing and charge.pending, and 20 s later the clock settles it, sending charge.updated, payment_intent.succeeded and charge.succeeded as Stripe does. SM33CC answers Stripe’s 400 payment_method_microdeposit_verification_attempts_exceeded and SM44DD answers the intent timed out, each failing the intent with a failed charge, payment_intent.payment_failed and charge.failed. Any other code or amounts answers Stripe’s 400 payment_method_microdeposit_verification_descriptor_code_mismatch with the attempts left, and the tenth fails the intent. A us_bank_account SetupIntent created with confirm=true now awaits microdeposits, and its verify succeeds at once and attaches the PaymentMethod to the customer. A us_bank_account PaymentMethod under Stripe’s test routing number 110000000 reads bank_name STRIPE TEST BANK and status_details {}, and the test account 000123456789 Stripe’s fingerprint. Before, the verify accepted any SM code, answered processing with no charge and no event, and nothing moved the intent after.

v0.0.16 — 2026-10-19

  1. A balance with no funds reads zeros. GET /v1/balance for an account holding no funds answers one usd entry at zero in available, pending and refund_and_dispute_prefunding, where a fresh connected account answered empty lists. A connected account’s balance also lists instant_available beside available, and the platform’s lists connect_reserved at zero, as Stripe’s do.

  2. Each account reads only its own balance transactions. Under Stripe-Account or Stripe-Context, GET /v1/balance_transactions lists only that connected account’s transactions, and with neither header only the platform’s, where every account’s used to list together. GET /v1/balance_transactions/{id} naming another account’s transaction answers 404 resource_missing “No such balance transaction”, as Stripe’s does, where it answered 200. A destination charge writes the connected account’s own payment transaction. Its source is a py_ payment and its amount the transferred amount. Its fee is the application_fee_amount, with one application_fee line in fee_details. The account lists and retrieves it under its acting header.

  3. A Checkout Session create refuses an expires_at outside 30 minutes to 24 hours ahead. POST /v1/checkout/sessions takes an expires_at from 30 minutes to 24 hours after the twin clock, both ends included, and answers any other integer with Stripe’s 400 invalid_request_error: a past value names param expires_at and quotes the value, a nearer or later one carries no param. No session is created. An omitted expires_at still defaults to 24 hours after the create.

  4. A Checkout Session expires when the clock reaches its expires_at. Once /_rystic/clock reaches an open session’s expires_at (24 hours after its create unless the create set it), the session reads expired with a null url, the expire endpoint refuses it as not open, and checkout.session.expired goes out naming no request, its created at the session’s expires_at even when one clock move jumps past it, as Stripe expires a session nobody completed; it no longer reads open past its deadline.

  5. A PaymentIntent or SetupIntent created without payment method types needs a return_url at confirm. A create that names neither payment_method_types nor automatic_payment_methods stores and answers automatic_payment_methods {enabled: true, allow_redirects: "always"}, as one sending only automatic_payment_methods[enabled]=true does, and its /confirm without a return_url answers 400 invalid_request_error with Stripe’s return_url message, the intent embedded and no code or param key. The same confirm with a return_url succeeds, as does any confirm of an intent created with payment_method_types or with allow_redirects never. A SetupIntent’s automatic_payment_methods.enabled reads as a boolean.

  6. A PaymentIntent created with confirm=true announces its charge before the settle. Its charge.succeeded carries balance_transaction null, as a confirm through /confirm does, and the balance transaction arrives with the one charge.updated about 3 s later on the twin’s clock, which a refund within 2.5 s of the payment drops.

  7. A credit note preview carries its effective_at. GET /v1/credit_notes/preview answers effective_at as the preview’s created timestamp, or as the effective_at the request sends, as Stripe does, where it used to answer null unless the request set one.

  8. A v2 account with the customer configuration pays as customer_account. A PaymentIntent update, a SetupIntent update and a PaymentMethod attach take customer_account as the PaymentIntent create does, and each answers it beside the cus_ ID of the account’s v1 customer, as do a refund of such a PaymentIntent and GET /v1/payment_intents?customer_account=. An unknown account answers 400 resource_missing. GET /v1/customers/{account id} answers that v1 customer under its own cus_ ID.

  9. customer_account is refused as Stripe refuses it. Every operation that takes customer_account answers an unknown or closed account with 400 resource_missing “No such customer: ‘<id>’” on param customer_account, and a subscription create answers it 404. The PaymentIntent and SetupIntent updates and the PaymentMethod attach also answer 400 for an account without the customer configuration and for customer sent beside customer_account, and the intents’ errors carry the unchanged intent. The cus_ ID of an account’s v1 customer reads back with customer_account set, and a PaymentIntent created for it carries customer_account. Acting as an account, a recipient configuration counts toward the configuration check only when it requested stripe_transfers, and the check comes before parameter validation.

  10. A customer create refuses a test clock that does not exist. POST /v1/customers with a test_clock that never existed, or one already deleted, answers Stripe’s 400 resource_missing on test_clock, “No such billingclock: ‘<id>’”, and creates no customer. Before, it created the customer on the twin’s clock.

  11. Deleting a customer cancels its subscriptions. DELETE /v1/customers/{customer} cancels each of the customer’s live subscriptions at once, as Stripe does: each reads canceled with ended_at at the delete, canceled_at there too unless a pending cancellation request keeps its own, and cancellation_details.reason cancellation_requested, and the clock no longer renews and bills it. customer.deleted now carries the customer as it stood, not the deleted stub, and goes out before each customer.subscription.deleted; deleting a test clock sends them in that order too.

  12. A declined charge is stored, listed and sent as charge.failed. A POST /v1/charges that a declining test token refuses still answers 402. The charge its error.charge names can now be retrieved and is listed, reading status: failed with Stripe’s failure_code, failure_message, outcome and the test card’s last4 and funding. A declined charge, and one a Radar block list refuses, now emits charge.failed to your endpoints and the event logs.

  13. A destination charge moves both balances. A PaymentIntent with transfer_data[destination] credits the platform the charge’s net, as any card payment does, then transfers transfer_data[amount] to the connected account when it is set, else the whole amount, and takes any application_fee_amount back from it. The connected account’s balance, which GET /v1/balance reads under Stripe-Account or Stripe-Context, gains the transfer less the fee. The platform’s moves by the net less the transfer plus the fee. Sending both transfer_data[amount] and application_fee_amount answers 400 parameters_exclusive, as Stripe does. The twin moves both balances at the charge; Stripe shows them seconds later.

  14. An invoice can name its payer by customer_account alone. POST /v1/invoices with customer_account and no customer checks only the account, so an unknown or closed one answers 400 resource_missing with param customer_account and the account ID, where it answered a missing customer.

  15. A PaymentIntent with automatic payment methods lists Link. A create with automatic_payment_methods[enabled]=true and no payment_method_types answers payment_method_types ["card", "link"] and a link entry in payment_method_options, as Stripe’s does, where it answered an empty list.

  16. A payout below 1 is refused with Stripe’s message. POST /v1/payouts with an amount of 0 or less answers 400 parameter_invalid_integer “This value must be greater than or equal to 1.”, where it answered “Invalid positive integer”.

  17. A connected seller’s capabilities activate on the clock. A v2 account created with its onboarding details reads card_payments pending until /_rystic/clock is 85 seconds past its created, then active, with or without a bank. Once it holds an external account, its payouts read pending from 18 seconds and active from 85, and without one they stay restricted. Each capability whose status moves sends its v2.core.account[configuration.<merchant|recipient>].capability_status_updated event, naming no request. Deactivating a configuration sets its capabilities to null, and re-applying it leaves them null. A card_payments re-requested after that reads active at once on an account past its activation, with stripe_balance.payouts active too when it holds a bank, so the seller charges again; on an account not yet activated it reads pending. Advance the clock by 85 seconds or more, or run with RYSTIC_PACE=1, to reach activation.

  18. A charge as a seller is refused as Stripe refuses it. A PaymentIntent created with confirm=true under Stripe-Account or Stripe-Context answers 400 “Your account cannot currently make charges…” while the seller’s card_payments is pending, or when its merchant and recipient configurations were both deactivated. It answers 400 “You cannot create a charge on a connected account without the card_payments capability enabled.” when only its merchant configuration was deactivated, re-applied or not. Either way the intent is still created, and the error carries it with status requires_payment_method, beside the payment method. An unconfirmed create is not refused. A POST /v1/charges under either header answers the same “Your account cannot currently make charges…” 400 while the seller’s card_payments is not active, with no PaymentIntent and no charge in the error.

  19. A renewal that would run past cancel_at bills only up to it. When the clock renews a subscription whose cancel_at falls before the next period end, the item’s period ends at cancel_at and the renewal bills that stub as a proration, paid an hour later, before the subscription cancels at cancel_at. Before, the renewal billed the whole period.

  20. A scheduled cancellation keeps its request time in canceled_at. A subscription with cancel_at_period_end or cancel_at that the clock ends now reads canceled_at as the time of the update that asked for it, ended_at as the end, and keeps cancel_at and cancel_at_period_end, as Stripe documents. A DELETE while such a request is pending keeps its canceled_at and clears cancel_at and cancel_at_period_end, as Stripe does. Before, canceled_at read the end.

  21. A subscription create takes trial_end and trial_settings. POST /v1/subscriptions now accepts trial_end, starting a trial that ends then and needs no payment method, and trial_settings[end_behavior][missing_payment_method] (cancel, create_invoice or pause), which trial_settings stores. Both were refused with parameter_unknown before. A trial’s items run from its start to trial_end, and its billing_cycle_anchor is trial_end unless the create sets one.

  22. Each subscription item renews on its own interval. A yearly item beside a monthly one is billed once a year and the monthly one every month, each renewal invoice carrying only the items whose period ended. An item added with POST /v1/subscription_items or an update’s items[] takes the subscription’s current period: that of an item with the same interval, the trial’s during a trial. Before, every item renewed at the earliest item’s period end, and an item added after the first renewal started at the subscription’s first period, so the next clock move wrote a renewal dated in the past.

  23. A subscription keeps its last item. An update whose items[] would leave a subscription with no item, and DELETE /v1/subscription_items/{item} on its only item, answer Stripe’s 400 invalid_request_error “A subscription must have at least one active plan. To cancel a subscription, please use the cancel API endpoint on /v1/subscriptions.” and change nothing. Before, both went through.

  24. A subscription whose payment fails goes past_due, and trial_end moves the trial. When a charge_automatically subscription’s invoice finalizes with no payment method, as at a trial’s end under missing_payment_method=create_invoice with no card, its payment fails: the invoice stays open with one attempt, the subscription reads past_due and the customer delinquent, and invoice.payment_failed goes out; paying the invoice returns both. A later trial_end on a trialing subscription moves billing_cycle_anchor, the period end and a period-end cancel_at with it and bills the added days on a paid zero invoice, and one at or before the subscription’s clock answers Stripe’s 400 with param trial_end. A trial’s invoice lines read Free trial for <quantity> × <product>. Before, the subscription stayed active with the invoice open, and a trial_end update only stored the new time, or ended the trial when it was past.

  25. A subscription period ends on its anchor’s day of the month. Periods are counted from billing_cycle_anchor, each ending on the anchor’s day or the month’s last day when the month is shorter, so a subscription anchored on Jan 31 renews on Feb 28, Mar 31, Apr 30 and May 31, and a yearly one anchored on Feb 29 renews on Feb 28. A resume under billing_cycle_anchor=unchanged aligns to the same days. Before, a Jan 31 anchor renewed on Mar 3 and drifted from there.

  26. Subscriptions bill prorations for partial periods. A create whose billing_cycle_anchor is ahead, with no trial, runs its first period to the anchor and bills it as a proration of the interval that ends there, with its own invoice item. Unless proration_behavior is none, an item added to an active or past_due subscription leaves a pending proration for the rest of the period, sent as invoiceitem.created, which the next renewal bills ahead of the new period; POST /v1/subscription_items now also sends customer.subscription.updated. A renewal into a period shorter than the price’s interval, after billing_cycle_anchor=now on an update or a cleared cancel_at, bills that period as a proration, and deleting an item drops its pending proration. A renewal of a monthly and a yearly item together is dated from the last monthly renewal. Before, the first period ran a full interval from the anchor, and an added item was billed nothing for the part of the period it joined.

  27. A resume of a subscription that is not paused answers Stripe’s message. POST /v1/subscriptions/{id}/resume on a trialing, active or other subscription that is not paused answers 400 invalid_request_error with “You can only resume a subscription if it is paused.”, as Stripe does. Before, the message named the subscription and its status.

  28. A resumed subscription stays paused until its resume invoice is paid. POST /v1/subscriptions/{id}/resume with billing_cycle_anchor=now (the default) now answers the subscription still paused, with a pending_update and an open subscription_cycle invoice for the period the resume starts, as Stripe does; the invoice is not charged on its own, and each resume writes another. Once you pay that invoice, or mark it uncollectible, the subscription reads active from the resume and sends customer.subscription.resumed, customer.subscription.pending_update_applied and customer.subscription.updated. A resume with billing_cycle_anchor=unchanged still reads active at once with no invoice. Before, every resume read active at once with no invoice.

  29. A subscription’s trial ends when the clock reaches its trial_end. Three days before (at once for a shorter trial), customer.subscription.trial_will_end goes out, after invoice.upcoming when the end will bill. At trial_end a subscription with a card, a send_invoice one, or one under trial_settings[end_behavior][missing_payment_method]=create_invoice reads active and renews into a subscription_cycle draft that finalizes an hour later: a send_invoice one is sent, a card pays a charge_automatically one, and with no card its payment fails and the subscription goes past_due; a billing_cycle_anchor after trial_end bills a proration up to it first. With no card, pause pauses it and cancel cancels it, and a later resume starts its period at the resume (with billing_cycle_anchor=unchanged, on the anchor’s schedule), billing nothing for the paused time. trial_end=now on an update bills the new period at once, and cancel_at_period_end then cancels at that period’s end. Every event the clock sends, a renewal’s and a cancel_at’s included, is now stamped at the instant it was due, even when one clock move jumps past it. Before, a subscription read trialing past its trial_end.

  30. A resume that keeps the anchor bills the rest of the period. POST /v1/subscriptions/{id}/resume with billing_cycle_anchor=unchanged still reads active at once with no invoice, and, unless proration_behavior is none, leaves a pending proration for the rest of the period it resumes in, which the next renewal bills with the period after it. The resume now sends customer.subscription.updated and customer.subscription.resumed. Before, the paused part of the period was free and the resume sent no event.

  31. A subscription update cannot touch another subscription’s item. An update whose items[] names an item of another subscription, or an ID no item has, answers Stripe’s 400 invalid_request_error “No subscription item with this ID (<id>) on the subscription.” and changes neither subscription. Before, it deleted or changed the other subscription’s item.

  32. Subscriptions on a test clock renew when the clock advances. A customer created with test_clock and its subscriptions live on the clock’s frozen_time: their created, periods, trial dates and invoices read it, and each invoice carries the clock’s test_clock. When an advance completes, every renewal, trial warning and end, draft finalize and cancel_at due in the advance happens at its own instant on the clock, while the events keep real time and the PaymentIntent and charge that pay an invoice carry no test_clock. /_rystic/clock moves only the subscriptions on no test clock, and one clock’s advance moves only its own. Deleting a test clock cancels the subscriptions on it and deletes its customers.

  33. A test clock’s advance completes. After POST /v1/test_helpers/test_clocks/{id}/advance, the next API request still reads the clock advancing with its old frozen_time, and the request after it reads the clock ready at the requested time with status_details {}, so it can be advanced again. Only requests the twin hands to a handler count, a world timeline call to an API route included: /_rystic/ control calls, and clock jumps and paced ticks that fire no timeline call, leave the clock advancing. An advance sent while the clock is still advancing answers 429 “Test clock advancement underway - cannot perform modifications”, as Stripe’s does, where it answered 400 and the clock never left advancing.

  34. A v2 account’s writes emit Stripe’s account events. A create emits v2.core.account.created plus a part event for each part it sets (v2.core.account[identity].updated, [defaults].updated, [configuration.merchant].updated, [configuration.recipient].updated, [configuration.customer].updated) and a capability_status_updated with data.updated_capability for each capability status it sets; an update emits v2.core.account.updated and the merchant part and capability events it causes; a close emits v2.core.account.closed. GET /v2/core/events lists them, never /v1/events, and a part event’s related_object.url carries ?include=<part>, which GET /v2/core/accounts/{id} accepts, so stripe-node’s fetchRelatedObject resolves it. An event’s changes takes Stripe’s shape: a created event’s is {before: null, after: <the account's top-level fields>}, a closed event’s the reverse, an updated event’s the moved top-level fields, and a part or capability event’s the whole part on each side. A part can hold fewer members than Stripe’s, which fills some objects and lists every payment method capability.

  35. GET /v2/core/events answers 10 events a page by default. A list sent without limit answers 10 events, as Stripe’s does, and its relative next_page_url carries the page token and only the parameters the request sent, so the next page reads 10 again.

  36. Balance settings follow the seller’s activation. Under Stripe-Account or Stripe-Context, GET and POST /v1/balance_settings read payments.payouts.status enabled and payments.debit_negative_balances false once the account’s stripe_balance.payouts capability is active, as Stripe’s do, and disabled and true before.

  37. Acting as a closed account is refused. A Stripe-Account or Stripe-Context header naming a v2 account the key closed answers 403 account_invalid (api_error), the answer for an account the key does not hold, on reads and writes alike, and the request creates nothing, as Stripe’s does. The twin used to act as the closed account.

  38. A destination charge names its seller, transfer group, transfer and fee. A destination charge’s Charge carries destination (the connected account), transfer_group (group_ plus the PaymentIntent’s ID when the request names none, also on the PaymentIntent), transfer (its tr_ transfer) and, with an application_fee_amount, application_fee (its fee_ ID). The twin names transfer and application_fee at the charge, where Stripe names them about 6 seconds later.

  39. A destination charge lists the platform’s transfer and application fee. After a destination charge, the platform’s GET /v1/balance_transactions lists a transfer row for minus the transferred amount, its source the charge’s tr_ transfer, and, with an application_fee_amount, an application_fee row for plus the fee, its source a fee_ application fee, beside the charge row, so the list adds up to the platform’s balance as Stripe’s does.

  40. A destination charge tells the connected account about its payment. The py_ payment a destination charge makes emits payment.created to the connected account: the account’s GET /v1/events lists it, with data.object the payment and request {id: null, idempotency_key: null}, and the platform’s Connect endpoints receive it, while the platform’s own event list never shows it, as Stripe’s does. An endpoint’s enabled_events accepts payment.created. Stripe lists the event about 3 s after the charge, and the twin emits it at once.

  41. A destination charge’s refund can reverse the transfer and the application fee. A refund with reverse_transfer=true takes its share of the transfer back from the connected account: the account lists a payment_refund row and a pyr_ refund of its payment, the platform a transfer_refund row, and the refund’s transfer_reversal names a trr_ reversal. With refund_application_fee=true it returns its share of the application fee: the account lists an adjustment row and the platform an application_fee_refund row. Each refund’s shares are its own proportion of what the charge captured, rounded half up and never more than remains, and the refund whose reversal leaves the transfer fully reversed returns the rest of the fee, as Stripe’s do. Without refund_application_fee the account keeps paying the fee, so its balance can go negative, as Stripe’s does. A refund naming neither moves only the platform’s balance.

  42. A connected account’s events are its own. An event a request causes while acting as a connected account (Stripe-Account or Stripe-Context) belongs to that account: its v1 snapshot carries account and context naming it, its v2 rendering context, and GET /v1/events and GET /v2/core/events list and retrieve it only when read as that account, as Stripe’s do. Every other event stays the platform’s and names no account. A webhook endpoint created with connect=true is a Connect endpoint: it renders application (a stand-in ca_ ID, where Stripe names the platform’s own Connect application) and takes connected accounts’ events alone, while every other endpoint takes only the platform’s. No endpoint renders connect.

  43. A payout writes its balance transaction. POST /v1/payouts writes a payout balance transaction for minus its amount, the one the payout’s balance_transaction names, so GET /v1/balance_transactions/{id} retrieves it and the list, filtered by type=payout or source too, includes it. An account’s transactions now add up to its balance after a payout.

  44. A payout goes to the account’s own bank account and arrives on a day boundary. A payout made as a connected account names, as its destination, the bank account POST /v1/accounts/{account}/external_accounts returned for that account, where the twin used to name a new ba_ ID on each payout. The platform’s payouts all name one stand-in bank account. arrival_date is midnight UTC of the payout’s day, as the measured payouts read, where it used to equal created.

  45. A connected account reads the payment a destination charge makes. Under Stripe-Account or Stripe-Context, the connected account retrieves its py_ payment with GET /v1/charges/{id} and finds it in GET /v1/charges, as Stripe’s does. The payment is a Charge for the transferred amount whose source_transfer names the platform’s transfer and whose balance_transaction is the account’s payment row.

  46. A v2 account’s identity never echoes an ID number. A stored identity keeps only each ID number’s type, us_ssn followed by us_ssn_last_4, never its value, in include=identity reads and in the v2.core.account[identity].updated event alike, and its address countries read upper-case (US), as Stripe’s do.

  47. include=requirements lists what an account still owes. A v2 account read, listed, created or updated with include=requirements answers {entries, summary}. An onboarded seller lists external_account until it holds a bank account, and the identity checks Stripe’s verification works through until its activation. Once it holds a bank account and is active, it answers entries: [] with summary: null. An account not onboarded whole lists only external_account, where Stripe names each missing field. future_requirements reads null.

  48. A DELETE takes its parameters in the query string. DELETE /v1/subscriptions/{id}?invoice_now=true&prorate=true, sent without a body as Stripe’s SDKs send a DELETE, now cancels the subscription; it answered 400 parameter_unknown. A DELETE sent without a body reads its documented parameters from the query string, and any other query parameter is still refused. A DELETE that carries a form body still takes its parameters from the body alone.

  49. The twin’s clock acts as the platform. What the clock settles (a subscription’s renewal, trial warning, trial end and cancel_at, a Checkout Session’s expiry, a test clock’s completion, a seller’s capability moves) writes its PaymentIntent, charge, balance entry and events to the platform, whichever account the request that brings the clock there acts as. They used to land on the connected account that request named in Stripe-Account or Stripe-Context.

v0.0.15 — 2026-10-18

  1. A v2-native event stays off /v1/events. An event of a v2. type, such as the event destination ping, is logged only as a v2.core.event: GET /v1/events no longer lists it, its v1 ID no longer retrieves it, and no webhook endpoint receives it, as on Stripe.

  2. A failed webhook delivery is retried, each retry signed again. A delivery your endpoint or thin event destination does not answer with a 2xx is retried three times, 30 min, 1 h and 2 h after each failure, or as soon as /_rystic/clock moves the clock past a retry’s due time. Each retry carries the same body and a new Stripe-Signature, so constructEvent and parseEventNotification accept it, and none goes to an endpoint deleted or disabled by then. The delays are assumed, since Stripe documents only three sandbox retries over a few hours. The delivery report on GET /_rystic/egress adds retries_armed, next_retry_at and next_retry_clock; pending still counts a retry waiting out its delay, so a test that waits for pending to reach 0 after a failed delivery waits through the retries unless it moves the clock past them.

  3. An event’s pending_webhooks counts the endpoints yet to take it. A v1 event’s pending_webhooks is the number of enabled webhook endpoints subscribed to it when it was logged, less each that has answered a delivery with a 2xx, so GET /v1/events?delivery_success=false lists an event still waiting on a retry or whose retries all failed, where it used to read 0 always. Thin event destinations count nothing; with RYSTIC_WEBHOOK_DELIVERY off, nothing lowers the count.

  4. A card confirm at create without a return_url is refused as Stripe refuses it, a charge’s balance transaction can be read, and the charge sends charge.updated. POST /v1/payment_intents or /v1/setup_intents with confirm=true and neither payment_method_types nor automatic_payment_methods answers Stripe’s 400 without a return_url and creates no intent. A succeeded charge names its balance transaction at the confirm and, about 3 s later on the twin’s clock, sends one charge.updated whose previous_attributes carry balance_transaction; a refund within 2.5 s of the charge supersedes that event, and a refund names its own transaction at create. GET /v1/balance_transactions/{id} serves a charge’s or a refund’s transaction with Stripe’s amount, fee, net, type and status, and GET /v1/balance_transactions lists them, filtered by source or type. Not yet as Stripe: a create with automatic_payment_methods[enabled]=true and no allow_redirects=never is accepted without a return_url, the /confirm routes still accept an intent created without payment_method_types and no return_url, the charge’s transaction reads at once where Stripe attaches it about 3 s later, and the event’s previous_attributes carry no receipt_url.

v0.0.14 — 2026-10-16

  1. A card can be blocked, and Radar’s default lists stay fixed. A card whose fingerprint is on the default card_fingerprint_blocklist is refused with 402 card_declined, as a blocked email is, by PaymentIntent create with confirm=true, confirm and charge create, and pays again once the item is deleted. Each test card reads one card.fingerprint on every path, so tok_visa, pm_card_visa, a saved card and a charge’s card agree. Renaming or deleting one of the 24 default lists answers Stripe’s 400.

  2. Charge search filters by its query. GET /v1/charges/search now returns only the charges that match the query, as Stripe does: amount, created, currency, customer, disputed, refunded, status, metadata['key'], billing_details.address.postal_code and the card fields under payment_method_details, joined by AND or OR. A metadata['order_id']:'…' lookup works, and an unsupported field or operator, an unquoted string or a query that mixes AND and OR answers 400 with Stripe’s message.

  3. Every refund route refuses what Stripe refuses. POST /v1/refunds, POST /v1/charges/{charge}/refunds and POST /v1/charges/{charge}/refund now answer the same 400s: charge_disputed for a disputed charge, charge_already_refunded for a fully refunded one, and Stripe’s “Refund amount ($20.00) is greater than …” message for an over-refund. After a partial capture, a refund is limited to the captured amount, and a refund with no amount refunds only that.

  4. GET /_rystic/egress shows the twin’s URLs. An event’s hosted_invoice_url, invoice_pdf and receipt_url in the egress log are on the origin you read it on, as in the delivered event and GET /v1/events, never on invoice.stripe.com or pay.stripe.com; ?raw=1 returns Stripe’s hosts.

  5. Paying an invoice charges the customer’s default card, and invoice payments are served. POST /v1/invoices/{invoice}/pay with no payment_method charges the invoice’s default_payment_method, else the customer’s invoice_settings.default_payment_method, and the PaymentIntent and charge it makes name that card, so a default bypassPending card’s funds land in available. GET /v1/invoice_payments (filtered by invoice, status or payment) and GET /v1/invoice_payments/{invoice_payment} read the payment that names the invoice’s PaymentIntent.

  6. v2 events and event destinations. GET /v2/core/events lists and GET /v2/core/events/{id} retrieves every event your requests cause, as v2.core.event objects under their own evt_test_ IDs, filtered by object_id, types or created, newest first and paged by next_page_url. /v2/core/event_destinations creates, lists, retrieves, updates, deletes, enables, disables and pings destinations; with RYSTIC_WEBHOOK_DELIVERY=on, a thin destination receives each event it enables as a signed thin notification. GET /v1/events lists the same events as v1 snapshots, now including those from a customer update, a declined payment, invoice items, invoice updates, finalizing, paying, voiding and marking uncollectible, credit notes, payouts and PaymentMethod updates and detaches.

  7. Subscriptions bill their first invoice, payouts read paid, and each event’s changes, reason and order match Stripe. A subscription create writes its first invoice, and with charge_automatically and a default card it pays it at once: latest_invoice retrieves a paid invoice with its PaymentIntent and charge, and the create sends the nine events Stripe sends, in Stripe’s order. cancel_at_period_end=true sets cancel_at to the period end, and the subscription ends there when the clock reaches it. A created payout reads paid on every later read, as test mode does, so canceling it answers “Payouts can only be canceled while they are pending.” Events carry changes only where Stripe’s do, and payout.created and a new refund’s settling refund.updated and charge.refund.updated name no request. A declined payment, invoice pay and a credit note’s refund emit in Stripe’s order, and an invoice item added to an invoice sends invoice.updated. Event destinations answer events_from: ["@self"], refuse an .invalid URL, an unknown snapshot_api_version and an include other than webhook_endpoint.url, and an unknown v2 event answers “No such event: ’…’”.

  8. stripe-node pages and filters v2 events. next_page_url and previous_page_url on GET /v2/core/events and GET /v2/core/event_destinations are relative paths, as Stripe answers them, so an SDK’s autopaging follows them; the indexed types[0]=…&types[1]=… the SDK sends filters as Stripe does; and an event’s request.id names the answer’s Request-Id when the request carries an Idempotency-Key.

  9. Swagger UI to read and call the twin from the browser. /_rystic/swagger, linked from the state UI header, is Swagger UI over the published API trimmed to the operations this twin serves; Try it out sends each request to the twin.

  10. Webhook endpoints and event destinations are one list, and late events arrive late. A v2 snapshot destination lists in GET /v1/webhook_endpoints and a v1 endpoint in /v2/core/event_destinations, and a delete on either route removes both. charge.updated follows a confirmed charge about 3 s later, unless the charge is refunded within 2.5 s of the confirm, which drops it for good, and payout.created lands after the payout’s updates, as Stripe sends them. A destination’s signing secret can no longer be derived from its ID.

  11. Subscriptions, items, refunds and charges carry every member Stripe returns. A subscription now has plan and quantity on every list and retrieve, even when seeded without them, and has test_clock, a flexible billing_mode by default and the other members Stripe answers; an item has plan and current_trial and no billed_until; a refund names its customer and payment_method and omits an unset description; a manual-capture charge carries capture_before. A charge no longer carries amount_updates, which Stripe does not return. A Checkout Session created without a return_url omits the key, an updated invoice answers payments only when expanded, and a seeded payment link’s list and update answers no longer carry the unexpanded line_items sublist.

  12. Refusals read as Stripe’s. An undeclared query or body parameter answers 400 parameter_unknown on every route (invalid_fields “Unknown field.” under /v2), and a refusal on a PaymentIntent or SetupIntent route carries the intent it names. A wrong Content-Type on /v2 answers 415. Card declines use Stripe’s current wording, a too-long metadata value is quoted as xxxx...xxxx, an unknown subscription item answers “Invalid subscription_item id”, an unknown credit grant answers 400 “Invalid credit grant ID”, and the v2 event destination refusals list their invalid_fields.

  13. A subscription without a card is refused or incomplete, and periods renew. A charge_automatically create with no card answers 400 resource_missing, or incomplete under payment_behavior=default_incomplete. A send_invoice subscription’s first invoice stays a draft for an hour, and each period end drafts a renewal invoice that is paid an hour later. Finalizing an invoice creates its PaymentIntent and invoice payment, which pay reuses and a void cancels.

v0.0.13 — 2026-10-15

  1. A credit grant writes its credit balance transaction. Creating a credit grant appends one billing.credit_balance_transaction of type: credit with credit.type: credits_granted, the grant’s amount, its ID as credit_grant and its effective_at, as Stripe does. GET /v1/billing/credit_balance_transactions?customer=… lists it and GET /v1/billing/credit_balance_transactions/{id} returns it. Voiding or expiring a grant still appends no transaction.

  2. The balance moves with your money, payouts draw on it, and refunds of uncaptured charges release them. GET /v1/balance answers a usd entry in available and pending from the first request. A captured card payment adds its amount less Stripe’s 2.9% + 30¢ fee, to available for the tok_bypassPending / pm_card_bypassPending test card and to pending for every other card; a PaymentMethod made from tok_bypassPending is the card ending 0077, and once saved it still settles to available on every PaymentIntent path. A refund takes its amount back out, an invoice paid by card credits the balance as a card payment does, a credit note’s refund_amount debits it as a refund does, and a payout draws on available: a payout above it, or in a currency the balance does not hold, answers 400 balance_insufficient, and canceling a pending payout returns its funds. Refunding a charge created with capture=false releases the authorization, as Stripe does, and capturing it afterwards answers charge_already_refunded. A charge a PaymentIntent holds for capture is refused with Stripe’s message to cancel the PaymentIntent instead. A pm_card_pendingRefund payment refunds pending, and canceling a card refund answers Stripe’s “Canceling this refund is unsupported.”

  3. Your key works as the Basic username. Every route reads the secret key from Authorization: Bearer <key> or as the HTTP Basic username, so Stripe’s curl form curl -u sk_test_…: now answers the invoice, invoice item, payout, product and credit balance transaction lists, which refused it with 401.

  4. Charges carry amount_updates. Every charge the twin answers, on create, retrieve, update, capture, refund, list and search, holds amount_updates: [], as a Stripe test-mode charge does under 2026-06-24.dahlia, so code that reads the field no longer finds it missing.

  5. A chargeback can be looked up. A charge paid with tok_createDispute, or a PaymentIntent paid with pm_card_createDispute, opens a needs_response Dispute as Stripe does: the charge reads disputed: true and names it in dispute, charge.dispute.created is sent, and GET /v1/disputes, GET /v1/disputes/{dispute} and GET /v1/charges/{charge}/dispute return it. POST /_dispute stores the Dispute it opens, design_studio’s disputed charge carries its Dispute, and a charge with no dispute answers “No dispute for charge” rather than “No such charge”.

  6. Hosted invoice, receipt and payment link URLs lead to the twin. A finalized invoice’s hosted_invoice_url and invoice_pdf, a credit note’s pdf, a charge’s receipt_url and a payment link’s url are on the origin you called, as a Checkout Session’s url already is, so an agent that hands a shopper the link, or fetches it, never reaches production Stripe. The twin serves no page at those paths yet: they answer 404.

  7. An invoice item is a line of its invoice, paying an invoice charges its card, and a credit note moves money. An invoice item created with invoice set to a draft becomes one of its lines, so the draft’s subtotal, total and amount_due include it and finalize answers open with that amount due; updating or deleting the item moves or drops its line, and pending_invoice_items_behavior=include pulls the customer’s pending items onto a new draft. An item priced through price_data costs unit_amount times quantity. Paying an open invoice charges payment_method, else the invoice’s default_payment_method, through a succeeded PaymentIntent and charge, and expand[]=payments on the invoice lists the invoice_payment naming that PaymentIntent. Invoice search takes single-quoted values such as customer:'cus_…' AND status:'paid'. On a paid invoice a credit note’s refund_amount refunds the invoice’s charge and is listed under its refunds, credit_amount lowers the customer’s balance and names a customer_balance_transaction, and an amount sent to none of them answers Stripe’s 400; a credit note whose invoice is not open can no longer be voided.

  8. pm_card_visa becomes a saved card. Attaching pm_card_visa or another test payment method to a customer saves a new pm_… card of that brand to them, and a declining one (pm_card_chargeDeclined, …) answers 402 card_error as Stripe does. A PaymentIntent that names a test payment method now names a new pm_… instead of the token, and a payment confirmed with a customer and setup_future_usage saves that card, so the customer’s payment-method list returns it. Confirm takes setup_future_usage.

v0.0.12 — 2026-10-14

  1. The page describes the hosted portal and disputes as the twin serves them. The v0.0.10 notes no longer say the twin serves no portal page (/p/session?secret=… opens a working portal), and the Limitations section names POST /_dispute, the world action that opens a dispute on a charge.

  2. The state UI is included on Free. /_rystic/ui is available on every plan, including Free and the no-signup trial. The Pro-only verified UI download is a separate feature.

  3. A JSON body is refused, as Stripe refuses it. A POST whose nonempty body is sent under any Content-Type other than application/x-www-form-urlencoded now gets Stripe’s 400 invalid_request_error (“Invalid request (check that your POST content type is application/x-www-form-urlencoded)…”) instead of being read as form. Send form-encoded bodies, as the Stripe SDKs do; a bodiless POST such as /cancel is unaffected.

  4. Setting the clock never stamps records before the timeline events it fired. When POST /_rystic/clock {"time": ...} fires timeline events due at its target, the next request’s records stamp after theirs instead of reusing their instant; a set past every event still stamps one millisecond past its target, as before.

  5. A paced clock keeps wall time, and rystic run --speed 0 stops it. Under RYSTIC_PACE, each tick moves the clock by the wall time that passed since the last, so a tick missed while the twin was busy no longer makes timeline events fire late. rystic run hands the twin only the pace it resolved, so --speed 0, a world without events, --tape and --live keep the clock manual even when your shell exports RYSTIC_PACE.

  6. Billing meters, alerts, credit grants, test clocks and 64 operations in all. The twin now serves billing meters (with event summaries), meter event adjustments, billing alerts, credit grants and the credit balance, customer portal configurations, test clocks, product features and subscription items. It also serves products list, update and delete; price update and search; payment methods list; charge and PaymentIntent search; charge capture and refunds on a charge; refund cancel; subscription resume, migrate and discount delete; and the PaymentIntent increment_authorization, apply_customer_balance and verify_microdeposits and SetupIntent verify_microdeposits actions. Error bodies from API routes carry request_log_url, and doc_url wherever they carry a code, as Stripe’s do; a 401, a refused non-form body and an unknown URL answer without them.

  7. Billing alerts answer filters: [], and seeded recurring prices read back whole. A billing alert created without usage_threshold[filters] now answers usage_threshold.filters as [], as Stripe does, on create, retrieve, list, activate, deactivate and archive. The setup’s recurring prices now carry type: recurring and recurring.interval_count, usage_type and meter, so a subscription’s embedded price matches Stripe’s.

  8. Lists refuse cursors and clamp limit as Stripe does. The billing alerts, credit grants, meters, products and test clocks lists refuse starting_after sent with ending_before. Credit grants, meters, products and test clocks also refuse a starting_after that names no record, and products refuses such an ending_before. The alerts, meters, portal configurations and products lists serve limit=0 and limit=101, with the page size clamped to 1..100, and refuse only a non-integer limit.

  9. Missing references answer 400, unknown query parameters are refused, and list order is stable. A body or query parameter naming a record that does not exist answers 400 invalid_request_error with code resource_missing, as Stripe does, on price create (product), credit grant create and portal session create (customer, including a deleted one), the refunds list (charge, payment_intent) and the setup attempts list (setup_intent); a billing alert naming no meter answers Stripe’s 400 “The provided meter ID does not exist.” Meter event summaries and payment method attach check the customer before the path record. Twenty-five list and search GETs refuse a query parameter they do not take with 400 parameter_unknown, and the customers list takes expand and test_clock. The seeded charge, PaymentIntent, prices and product are dated before the twin’s clock starts, so a record you create heads every newest-first list.

  10. Invoices, credit notes, payouts, disputes, Radar value lists and 46 operations in all. The twin now serves invoices (create, update, delete, add lines, finalize, pay, void, mark uncollectible, list, search and their lines), invoice items, credit notes (with preview and lines), the account balance, payouts (create, update, cancel, list), dispute reads (GET /v1/disputes, a dispute, and a charge’s dispute), customer and product search, a customer’s payment methods, and Radar value lists and their items. An address on the default email_blocklist list refuses a charge, a PaymentIntent confirm or a PaymentIntent create with confirm=true that sends it as receipt_email, with Stripe’s 402 card_declined and a failed charge whose outcome names the block list. A Checkout Session’s retrieve answers custom_fields, custom_text and shipping_options, as its list does.

v0.0.11 — 2026-10-12

  1. A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn.

  2. Hosted Checkout completes subscription sessions. Subscribing on a subscription-mode session’s page creates the customer and a subscription with the session’s prices, quantities and subscription_data[metadata], completes the session and redirects to success_url, sending customer.created, customer.subscription.created and checkout.session.completed to your registered endpoints in that order. Payment and setup sessions still answer 501.

  3. A subscription ends at its cancel_at. Once the twin’s clock reaches a subscription’s cancel_at (set through the API or the customer portal), the subscription reads canceled with ended_at set and customer.subscription.deleted goes to your registered endpoints, so a POST /_rystic/clock advance past it plays out the cancellation.

v0.0.10 — 2026-10-12

  1. The scripts in this archive are MIT-licensed. check.sh and the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them. LICENSE.txt no longer covers them; it still governs the twin itself.

  2. Webhook endpoints can receive their events. Boot with RYSTIC_WEBHOOK_DELIVERY=on and every event your requests trigger is POSTed to each enabled endpoint registered through POST /v1/webhook_endpoints whose enabled_events names it or *, signed with that endpoint’s secret in Stripe-Signature at wall-clock time so stripe.webhooks.constructEvent accepts it however the twin’s clock is pinned; without the variable nothing is POSTed.

  3. A world can carry its own timeline. A --seed file, or a complete world document posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios.

  4. A timeline call can hand what it minted to later events. A call event may add bind, {"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as ${name} in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a ${name} no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing.

  5. A Checkout Session’s url opens the twin’s hosted Checkout page. url now leads back to the twin instead of checkout.stripe.com, and the page shows the session’s line items, total and email with a link back to cancel_url. Submitting it is not simulated yet: it answers 501 and the session stays open.

  6. GET /v1/events lists the events your requests trigger. Creating a customer, a charge or any other object that triggers an event now also writes that event to /v1/events, under the same ID its webhook delivery carries, so GET /v1/events and GET /v1/events/{id} return it beside any seeded events.

  7. A world can open a dispute. POST /_dispute opens a dispute on a captured charge, the way a cardholder’s bank does: the charge reads disputed, a refund is refused with charge_disputed, and charge.dispute.created reaches the egress log and every endpoint registered for it. A world’s timeline schedules it like any other call.

  8. Products, prices, subscriptions, customer portal sessions and meter events. The twin serves 14 more Stripe routes: create and retrieve a product; create, retrieve and list prices; create, retrieve, update, cancel, list and search subscriptions; update a subscription item; create a customer portal session and a billing meter event. A subscription’s create, update and cancel emit customer.subscription.created, .updated and .deleted. A portal session’s url leads back to the twin’s customer portal (item 12).

  9. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time.

  10. Webhook signatures can follow a shifted receiver clock. POST /_rystic/clock {"wall_offset_seconds": N} signs every webhook delivery’s Stripe-Signature t= N seconds past the wall clock, so a receiver whose own clock you moved forward (as sandbox clock -advance does) still accepts it; 0 or /_rystic/reset puts it back.

  11. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and ${name} fills in body_text. A body may be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as /InitiateMultipartUploadResult/UploadId.

  12. A customer portal session’s url opens the twin’s customer portal. url leads back to the twin at /p/session?secret=…, which lists the customer’s subscriptions with their seats and renewal date. Changing seats, canceling at the end of the billing period (cancel_at set, cancel_at_period_end left false, as Stripe’s portal does) and taking a cancellation back each update the subscription and send customer.subscription.updated to your registered webhook endpoints.

v0.0.9 — 2026-10-11

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging.

  2. Answers carry the fields Stripe’s API reference fixes. Where a response would leave out, or send null for, a field the reference fixes for that route and status (a required constant such as object, a required field’s declared default, a variant’s tag), the twin now fills it in.

  3. Line items are built from the request’s line_items. GET /v1/checkout/sessions/{session}/line_items and GET /v1/payment_links/{payment_link}/line_items list one line item per line_items element of the create, in request order, each with its own li_ ID, the element’s quantity, the product name as its description, a Price (minted from price_data, inactive and one-time), and amount_subtotal equal to the unit amount times the quantity. On either list, a line naming an existing Price carries that Price with every field a Stripe Price requires. The twin holds no Prices, so the Price’s product and creation time come from its ID alone, the same on every session and link that names it, and the rest are defaults: active, one-time, per-unit, in the session’s or link’s currency (usd when the create gives none). Its unit amount stays unknown, so the line’s amounts are 0. A session no longer collapses its lines into one, or answers a null currency when every line names a Price. A payment link no longer answers the ID li_unknown or amounts of 0 on a line built from price_data, and neither list answers a Price that is only an ID.

  4. check.sh gives each run its own twin log. The twin’s log is no longer the shared /tmp/stripe-twin.log: each run writes it to a path of its own, so two checks running at once on one host no longer overwrite each other’s log or report a failure from the other’s. When the check leaves the twin running, its last line prints the log’s path; when it stops the twin, it removes the log.

v0.0.8 — 2026-09-30

  1. Charge lists include the full charge details. GET /v1/charges now includes the 24 fields previously available only when retrieving an individual charge, including billing details, payment-method details, outcome, and nullable references. Stored details are preserved, so callers can inspect charges directly from list results.

  2. Retries reuse the original response. Repeating a POST with the same Idempotency-Key and parameters returns the original response without creating another object. Reusing the key with different parameters returns 400 idempotency_error.

  3. Simulator limitations are easier to find. The HOWTO now explains unsupported behavior, incomplete object shapes, missing error fields, and scenario-clock differences, and the release notes point to it for current limitations.

  4. Stripe-compatible errors and response headers. Unknown URLs and trailing slashes now return Stripe’s invalid-request envelope. Supported routes include Request-Id and Stripe-Version, and echo a pinned Stripe-Version request header.

  5. Full list objects and successful-card seeds. PaymentIntent, refund and customer list items retain their full object fields. Seeded PaymentIntent reads include nullable fields present on created records, and customer list projections preserve explicit nulls. Successful card charges in the scenario pack include synthetic payment-method, outcome and balance-transaction details. PaymentIntent confirmation records card details and an authorized outcome on its charge. Key-set checks against the live API check list items, member reads, and seeded records.

  6. Expand related objects on reads. Charge and PaymentIntent retrieve/list routes and PaymentMethod retrieve accept expand[] for their modeled links. Nested paths, multiple paths and list expansions return canonical related objects. Invalid paths return 400 before the handler runs. Null references stay null. Expansion on other routes is not supported.

  7. Immediate test-card declines.

    Charge creation and PaymentIntent creation/confirmation recognize the measured immediate-decline test inputs and return HTTP 402 card errors with the matching code, decline code, message, parameter, and input-specific advice/network codes. The error includes synthetic request/charge identifiers and the measured PaymentMethod shape where applicable. A failed intent retains its requires_payment_method status and last_payment_error for subsequent reads.

    The probe generator supports service-declared negative body values and request headers. It measures refusal responses separately from optional state readbacks and keeps cleanup for prerequisites. A valid confirmation retry verifies that the persisted error clears before successful state is stored. Conditional decline-after-attachment inputs, failed-charge projection, and retry-limit cancellation remain outside this closure.

  8. Parameter validation.

    Invalid amount syntax, malformed customer email and webhook settings, oversized metadata, missing token/customer references, and conflicting payment settings return measured Stripe errors before successful writes. Charge currency is normalized to lowercase. Missing-source errors precede missing-amount errors, and missing parameters and error links use the measured response fields.

    Schema-derived probes independently exercise type, format, bounds, enum, map-key/value, unknown-parameter and missing-reference cases. Bounds that the live API accepts stay accepted: list limits outside 1–100 are clamped, while non-integer limits and created-time bounds are rejected. Complete error key sets distinguish absent fields from null fields. These checks do not establish validation of every Stripe parameter or every currency’s minimum. Repeated scalar query values use the last supplied value consistently for validation, pagination and created-time filtering. A scoped live receipt and seeded timestamp regression tests cover both orderings of duplicate inputs.

  9. The state UI shows the account as the Stripe Dashboard. On Pro and Enterprise, /_rystic/ui opens a live Dashboard view of the twin — Home charts, payments, refunds, customers, payment links, Checkout Sessions, webhooks and events — where you can refund, capture, cancel, edit customers, manage payment links and webhook endpoints and expire sessions through the twin’s own API, beside Your integration, Webhook egress and Clock cards.

v0.0.7 — 2026-09-26

  1. The guide starts from an API key and says what each plan reaches. HOWTO.md now opens with rystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, and lists PaymentIntent capture among the 50 routes. check.sh finds the API key rystic login stored as well as a license file, looked up in the CLI’s order, and rystic features marks the features and commands that need Pro or Enterprise.

v0.0.6 — 2026-09-25

  1. A new world, design_studio, now the default. --scenario design_studio boots Website Design Contractors’ own account: 30 clients as customers, 269 charges since November 2024 each naming its invoice, a declined card paid on a replacement, a refund, a dispute, a bank debit still pending, and seven unpaid invoices as open intents, for the same clients, invoice numbers and dates the studio’s Slack, Resend, PayPal and Lob worlds hold. A lease that grants no scenarios boots it instead of customer_with_card.

  2. Only design_studio ships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by --scenario, RYSTIC_SCENARIO, or POST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks from sim/testdata/scenarios/ through the control plane.

  3. Self-serve on the metered edition replaces the free edition. A Rystic account’s token runs the metered build: rystic login rys_… once, then rystic run or docker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone.

v0.0.5 — 2026-09-23

  1. A free edition. stripe-twin now has a free download: no license, one fixed world (customer_with_card) with reads and writes, and no control plane. Get it from https://www.rystic.ai/dl/free/stripe-twin ; the licensing docs’ free edition section says exactly what is in and out.

v0.0.4 — 2026-09-21

  1. Protected runtime contract 9. A trusted environment harness can require a private gateway credential on every request, including administrative routes. This lets the released Stripe HTTP twin serve as the protected auxiliary backend in MCP-backed Slack environments without exposing direct twin controls to the agent.

The sealed Stripe model and scenario pack are unchanged from v0.0.3. This release updates the shared runtime; it makes no new Stripe MCP compatibility or API-fidelity claim. Released multi-episode qualification is recorded separately from package availability.

v0.0.3 — 2026-09-14

  1. A fourteenth named world, busy_account_xxl. busy_account_xl proves a client follows a cursor. This one asks the next question: whether an answer still comes out right when it has to be assembled from many pages and many objects at once. Same account shape at 2011 live customers — 2305 payment intents, 2274 charges, 361 refunds, 535 checkout sessions, 158 payment links, 123 setup intents, 505 saved cards, 14 endpoints — with four things the smaller worlds do not hold: 81 charged-back charges whose owners carry the refunded money on their other orders, so the account’s net refunded figure appears on no single page; six customers holding a run of declines, with a unique worst; 240 live subscribers across four plans, sold as completed mode=subscription Checkout Sessions, beside one-time sessions and expired subscription checkouts at the same prices as decoys; and billing addresses, so “every customer in country X” is a read of all 2011 customers, which no Stripe list route filters on. It boots in about 0.3 seconds and holds about 75 MiB. ./stripe-twin --scenario busy_account_xxl, or curl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xxl"}'.

  2. busy_account_xl is now big enough to need paging. Stripe serves at most 100 records per list request, and the world shipped in v0.0.2 held fewer than that in every collection but customers — so a client could read the whole account with one ?limit=100 call per collection and never touch has_more or starting_after. It is now 1011 live customers, 1083 payment intents, 1047 charges, 168 refunds, 158 checkout sessions, 134 payment links and 131 setup intents, so every one of those answers has_more: true at limit=100 and the rest of the account is only reachable by cursor. (Webhook endpoints stay at 14, because Stripe caps an account at 16.) Everything the smaller world was authored for survives at the new size, with the same IDs: busy_account’s whole cast, saved cards whose last payment sits at 45, 20 or 7 days so an overdue report moves with its window, unpaid intents either side of 1-hour, 6-hour and 48-hour cutoffs, authorized holds that are cancelable but not unpaid, duplicate-shaped charge groups straddling 7-, 14- and 30-day reporting windows, open carts either side of 10, 20 and 60 minutes, and live payment links either side of 100, 400, 2000 and 5000 cents. Point a client at it when you want to find out whether your pagination, cursors and date windows are right: ./stripe-twin --scenario busy_account_xl.

  3. A charged-back charge can no longer be refunded. POST /v1/refunds against a charge whose disputed is true minted a refund and returned 200, where Stripe answers 400 charge_disputed — “Charge <id> has been charged back; cannot issue a refund.” The twin now refuses it the same way, for a full refund, a partial one and a payment_intent-targeted one alike, and in the same order live does: an unknown parameter or a non-positive amount is still rejected first, and the refusal precedes the amount-exceeds-remaining check. busy_account’s ch_busy_disputed is the charge to try it on.

v0.0.2 — 2026-09-12

  1. Separate authorization and capture works end to end. POST /v1/payment_intents/{intent}/capture is routed, and confirming an intent created with capture_method=manual now holds the authorization at requires_capture instead of settling it. Partial captures release the remainder, over-captures are refused with amount_too_large, and the charge goes from uncaptured to captured in place — the flow a capture-later integration actually tests.

  2. confirm=true on create does what the docs show. Creating a PaymentIntent with a payment method and confirm=true — the one-call form the Stripe docs use for off-session charges — now succeeds and mints the charge, instead of answering requires_payment_method as though the card had been declined. Creating with a payment method and no confirm correctly rests at requires_confirmation.

  3. A saved card belongs to its customer. Confirming a PaymentIntent with a PaymentMethod attached to a different customer is refused with Stripe’s own 400, rather than quietly charging one customer’s card against another’s intent.

  4. disabled=true disables a webhook endpoint. POST /v1/webhook_endpoints/{id} with disabled=true flips status to disabled; it was accepted and ignored.

  5. A thirteenth named world, busy_account_xl. busy_account is a month of traffic over eleven customers, and every question you can ask of it is answered by the first page of a list. busy_account_xl is the same account at 201 customers: the whole busy_account cast is still there, with the same IDs, plus 190 more customers with mixed histories — 43 saved cards, payments spread over the last 45 days, repeat charges that straddle a 7-, 14- or 30-day reporting window, thirteen open checkout sessions and fourteen webhook endpoints over overlapping event sets. Every collection answers has_more: true on its default page, so it is the world to point a client at when you want to find out whether your pagination, your cursors and your date windows are right. Boot it like any other: ./stripe-twin --scenario busy_account_xl, or curl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xl"}'.

v0.0.1 — 2026-09-11

The first release of the Stripe twin.

  1. A local Stripe for your integration tests. 49 routes across 11 resources: customers, payment intents, setup intents, payment methods, charges, refunds, checkout sessions, payment links, webhook endpoints and events. Point your Stripe client’s api_base at the twin and your existing suite runs against it unchanged — the Stripe SDKs need no patching. Nothing you send it is billed, rate-limited, visible in a dashboard, or shared with whoever else on your team is using the same test-mode account.

  2. It never reaches the network, and it needs no key. Any Authorization header is accepted, and the twin dials nothing — which is the point: a suite that hits api.stripe.com fails when Stripe is slow, and a suite on a shared test-mode account fails when a colleague clears it.

  3. Seed the world your test needs. POST /_rystic/seed takes a register map and lands it directly, so a test starts from the records it needs instead of creating them through the API one call at a time. GET /_rystic/registers lists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped.

  4. The same seed twice is the same world twice. POST /_rystic/reset empties the twin and POST /_rystic/seed-rng pins the generator, so IDs repeat run to run; POST /_rystic/clock fixes the twin’s notion of now, so created on every record is a value your test chose. Two identical seed flows leave byte-identical /_rystic/inspect dumps — which is what makes a failing test reproducible rather than merely re-runnable.

  5. Read back the webhooks you would have sent. Event and webhook egress is rendered into an ordered in-memory log (GET /_rystic/egress) rather than delivered, so you can assert on what would have been sent without standing up a receiver or running stripe listen.

  6. See what the twin holds. /_rystic/ui serves a live view of the registers and the requests hitting them, so a test that fails on a missing record shows you the world it actually ran against.

What is measured, and what is not. Fidelity is scored differentially against real api.stripe.com in test mode, per response field, not by eyeball: the last full-suite round was 131 of 161 probes on 2026-09-04, and the suite has since grown to 175 probes. Shortfalls recorded for this release included missing doc_url and request_log_url error fields, customer deletion acknowledging without performing, and objects serializing differently across endpoints. Customer deletion has since been fixed; the other two remain. Current known limitations are listed in HOWTO.md under Limitations. Rounds are re-run daily and each release states its own number.

Not in this release. Subscriptions, products and prices are modeled in separate twins and are not part of this one; Connect, Tax and Billing are not modeled at all.

Last updated on