Simulators
Stripe changelog
What changed in each Stripe simulator release, newest first.
v0.0.19 — 2026-11-05
-
Long clock moves run faster and hold less memory. Each event a
/_rystic/clockmove 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. -
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 aspreferred_locales[]onPOST /v1/customers/{customer},expandon an update, or the 58 payment method type hashes (acss_debit,affirm, …) ofPOST /v1/payment_methods, now goes through, and the twin ignores whatever it does not model.POST /v1/prices,POST /v1/subscriptionsandPOST /v1/payment_intents/{intent}/confirmstill refuse some of theirs. -
A request with no API key is refused on every route. As Stripe does, the twin answers
401You did not provide an API key.before it reads the path or body, in v2’s envelope (codeapi_keys_missing) on a/v2/route; it used to serve such a request on every route but five lists. Send a placeholdersk_test_...key on every request. -
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/chargesor its capture route, by an invoice’s payment or by a subscription’s first invoice now writes thechargebalance transaction itsbalance_transactionnames, soGET /v1/balance_transactions/{id}answers it andGET /v1/balance_transactions?source=<charge>lists it, where the twin answered 404 and listed nothing.expand[]=balance_transactionon a charge embeds it, as Stripe does; the twin refused that expansion before. -
A value outside a parameter’s enum is refused. As Stripe does, the twin answers
400invalid_request_errornaming the parameter (Invalid tax_exempt: must be one of exempt, none, or reverse) before it stores or acts on anything, and v2’sinvalid_fieldsenvelope on a/v2/route; it used to storetax_exempt=bogusor refund withreason=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, andoriginon a refund create is now refused as an unknown parameter, as Stripe refuses it. -
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_invoiceand an invoice event’s object) now carries the 74 fields Stripe returns, among themaccount_country,account_name,customer_email,customer_name,customer_address,customer_tax_exempt,total_discount_amountsandtotal_pretax_credit_amounts, 22 of which a subscription’s invoice lacked. It no longer carries a top-levelsubscription(readparent.subscription_details.subscription, as on Stripe),amount_paid_off_stripe, or, outsideexpand[]=payments,payments.webhooks_delivered_atreads the invoice’screated, where a subscription’s invoice read null and a finalized one-off invoice read its finalize time. -
A PaymentIntent create or confirm refuses a payment method it can’t find, and the fraud test cards decline.
POST /v1/payment_intentsandPOST /v1/payment_intents/{intent}/confirmnaming a payment method that is neither a stored PaymentMethod nor a test card the twin serves, such aspm_card_doesNotExistorpm_doesNotExist, now answer Stripe’s 400resource_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 answeredsucceeded. A Stripe test card the twin doesn’t serve, such aspm_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_chargeDeclinedFraudulentandpm_card_radarBlocknow decline withcard_declinedanddecline_codefraudulentat create withconfirm=trueand on confirm, where they succeeded. -
The twin documents its own control plane in Swagger.
/_rystic/swaggernow 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/_disputeroute for raising a dispute on a charge. -
A state dump seeds back as it was, and the next record gets a fresh id.
GET /_rystic/inspectposted toPOST /_rystic/seedafter 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=sequenceslists every id counter, and a world document’s newsequenceskey restores them, so ids the simulator renders from a counter continue where the dump left off. -
Coupons, promotion codes and invoice previews. The simulator serves
/v1/coupons(create, retrieve, update, delete, list),/v1/promotion_codes(create, retrieve, update, list) andPOST /v1/invoices/create_preview, and/_rystic/seedtakesstate_couponandstate_promotion_coderecords. -
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.
-
A one-time tiered price is refused, and invoices redeem their discounts. Creating a tiered price without
recurringanswers 400invalid_request_error, paraminterval, “Prices withtype=one_timeare not supported with tiered billing.”, as Stripe does. Creating an invoice or invoice item whosediscountsname a promotion code or coupon redeems it: itstimes_redeemed, and the promoted coupon’s, grow by one, and oncemax_redemptionsis used the code readsactive: falseand the couponvalid: 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. -
An invoice preview prices packs, sub-cent prices and line discounts, and the price list matches Stripe.
POST /v1/invoices/create_previewbills a price withtransform_quantityby whole packs, bills aunit_amount_decimalprice at its exact decimal rounded half up once per line, and applies a line’s owndiscountsto that line before the subtotal.GET /v1/priceswithout anactivefilter lists archived prices too. A price’scurrency_optionsappears only when the request expands it, and then holds the price’s own currency beside the others. -
The call log shows what each call asked for and whether it succeeded. Each
GET /_rystic/callsentry now carriesargs, the call’s query and body arguments with credentials redacted (long values cut, file contents left out), andok, false for an answer that is not 2xx. A path the simulator does not serve is markedunknown_route: true.?tail=Nreturns only the last N calls, anddroppedcounts the calls the log no longer keeps. -
The docs are one page, with a page per feature. The archive ships
README.mdin place ofHOWTO.md, with the same sections in the same order as every Rystic simulator’s page, andCHANGELOG.mdbeside 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 underdocs/features/, and every example on these pages runs as written against a fresh simulator. -
The docs page links the admin token section. The page now sends you to the admin token’s section of the shared-simulators page.
-
Scenario descriptions use customer terms. The Scenarios table and
--list-scenariosdescribe each scenario in American spelling, with no internal references. -
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. -
Draft invoice discounts carry their amounts and expand into Discount objects. An invoice created with
discountsanswers them asdi_ids, andexpand[]=discountsorexpand[]=lines.data.discountson an invoice retrieve (data.discountson a list) answers each as a Discount object naming itspromotion_codeandsource.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’sdiscount_amountsand the invoice’stotal_discount_amountslist 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 adiscountid that is not the customer’s own, or naming one that does not exist, is refused, as Stripe does. -
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.
-
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
-
A customer base moves through two years in seconds. A
/_rystic/clockmove 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. -
A subscription’s automatically paid invoices send
invoice_payment.paid. A create that pays its first invoice from a card now sendsinvoice_payment.paidbetweeninvoice.createdandinvoice.paid, a renewal or trial-end draft the clock finalizes and pays sends it afterinvoice.payment_succeeded, and a retried payment that succeeds, an update’strial_end=nowand analways_invoiceitem add that pay from the card send it too, each carrying the paid invoice payment (statuspaid,invoicethe invoice’s ID,amount_paidthe amount,paymentnaming 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. -
A subscription’s renewal is announced seven days ahead.
invoice.upcomingnow 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. Asend_invoicesubscription’s renewal and trial-end drafts, the open invoices they become, itsinvoice.upcomingpreviews and a cancel’s final invoice now readnext_payment_attemptnull, as Stripe documents for everysend_invoiceinvoice; before, the drafts, their open invoices and the trial-end preview carried the finalize hour.
v0.0.17 — 2026-10-20
-
Requests stay fast as subscriptions accumulate. A request’s cost no longer grows with the number of subscriptions in the world: a
GETon a world of 1,000 subscriptions answers as fast as on an empty one, and creating 1,000 subscriptions and moving/_rystic/clockthrough three months takes seconds instead of minutes. -
pm_card_chargeCustomerFailis declined on a PaymentIntent, an invoice pay and a subscription create too. A PaymentIntent created withconfirm=trueor confirmed with the card, and an invoice pay charging it, now answer Stripe’s 402card_error(card_declined,generic_decline) where they used to succeed, leaving afailedcharge and the PaymentIntentrequires_payment_method, sent ascharge.failedandpayment_intent.payment_failed; the invoice a pay names stays open with one attempt and sendsinvoice.payment_failed. A subscription create charging the card readsincompletewith its first invoice open, and underpayment_behavior=error_if_incompleteit answers the 402, creates no subscription or invoice and cancels its PaymentIntent. A subscription update’strial_end=nowcharging the card is declined the same way, its invoice left open. -
A JCB charge reads
electronic_commerce_indicatornull.POST /v1/chargeswith a JCB test card answerspayment_method_details.card.electronic_commerce_indicatornull, as a PaymentIntent’s charge already did, where it used to read07. -
A refund made through
POST /v1/charges/{charge}/refundsends four more refund members in its events. Itsrefund.created,refund.updatedandcharge.refund.updatedobjects carrycustomer_account,receipt_number,source_transfer_reversalandtransfer_reversalas null, as Stripe sends them and as a read of the refund already answered, where the events used to leave them out. -
Both charge refund routes write the refund’s balance transaction. A refund of a captured charge through
POST /v1/charges/{charge}/refundsor the legacyPOST /v1/charges/{charge}/refundnames its balance transaction as one throughPOST /v1/refundsdoes:GET /v1/balance_transactions/{id}reads it as typerefund,pending, the refunded amount negated with no fee, andGET /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 issucceededat once as Stripe’s is. -
A Checkout Session create refuses an
expires_atthat is not an integer.POST /v1/checkout/sessionsanswers anexpires_atsuch asabc,1e9,1800.0, an empty value or one with a leading space with Stripe’s 400parameter_invalid_integer, namingparamexpires_atand quoting the value as sent, and creates nothing, where it used to ignore it and apply the 24-hour default. A seeded session whoseexpires_atis missing, or not a timestamp, now reads, lists and expires a day after itscreated, where each of those used to read it differently. -
A credit note create refuses an
effective_atStripe refuses.POST /v1/credit_notesanswers aneffective_atthat is not an integer, an empty value or one with a leading space included, with Stripe’s 400parameter_invalid_integer, one before the invoice was finalized withSpecified effective date must be after the associated invoice's `finalized_at`., and one after the clock withSpecified 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 noinvoicestill answersparameter_missingfirst. -
A credit note preview refuses an empty
invoice, and a missing invoice is refused beforeeffective_at.GET /v1/credit_notes/previewanswersinvoice=with Stripe’s 400parameter_invalid_emptyoninvoice, where it used to answerparameter_missing. A preview or a create naming an invoice that does not exist answersresource_missingoninvoicewhatever itseffective_at, as Stripe’s preview does, where aneffective_atthat is not an integer or in the future used to be refused first. -
A credit note preview refuses an
effective_atStripe refuses.GET /v1/credit_notes/previewanswers aneffective_atthat is not an integer, an empty value or one with a leading space included, with Stripe’s 400parameter_invalid_integer, one before the invoice was finalized, however far back, withSpecified effective date must be after the associated invoice's `finalized_at`., and one after the clock, however far ahead, withSpecified effective date must be within past 5 years., where it used to preview any value it could read. A preview with noinvoicestill answersparameter_missingfirst. -
A credit note’s refund names a balance transaction you can read. A credit note with a
refund_amountnow writes the balance transaction its refund names, soGET /v1/balance_transactions/{id}and?source=<refund>answer it: typerefund,pending, the refunded amount negated with no fee, and describedREFUND FOR CHARGE (Payment for Invoice)for an invoice payment’s charge, as Stripe’s is. The refund readsreasonnull, as Stripe’s does, and its acquirer reference no longer repeats one a refund made through another route holds. -
A customer’s balance is applied to its next invoice. A draft invoice now reads
starting_balanceas its customer’sbalanceandamount_duereduced by it, never below 0: after a -500 credit, a draft with a 1200 item readsamount_due700 withtotal1200 andending_balancenull. Finalizing reads the balance again, setsending_balance(0 once a credit is spent), asks the PaymentIntent for the reduced amount, moves the customer’sbalanceto the invoice’sending_balanceand sendscustomer.updatedbefore the finalize’sinvoice.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 readstarting_balance0 and the balance was never spent. Theapplied_to_invoicecustomer balance transaction is not modeled. -
A customer created with
payment_methodhas that card attached.POST /v1/customerswithpayment_method=pm_card_visa, or a storedpm_ID, attaches the PaymentMethod to the new customer asPOST /v1/payment_methods/{id}/attachdoes: it lists underGET /v1/customers/{id}/payment_methodsandGET /v1/payment_methods?customer=…&type=card,invoice_settings[default_payment_method]naming the same card stores the attached card’s ID, and the create sendspayment_method.attachedthencustomer.created. An unknownpayment_methodanswers Stripe’s 400resource_missingon that parameter and creates no customer; an empty one answers 400parameter_invalid_emptyand a hash or an array 400 “Invalid string: …”, as the same forms oftest_clockdo (inferred from those, unmeasured); a declining test token such aspm_card_chargeDeclinedanswers the attach route’s 402card_error, and a detached card or one another customer holds its 400invalid_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. -
A customer create refuses an empty or non-string
test_clock.POST /v1/customerswithtest_clock=answers Stripe’s 400parameter_invalid_emptyontest_clock, and one with a hash such astest_clock[x]=yanswers 400 “Invalid string: {“x”:“y”}” ontest_clock; neither creates a customer. Before, both created one. -
Deleting a customer leaves its subscriptions’ draft invoices uncollectible.
DELETE /v1/customers/{customer}now marks each draft invoice of the subscriptions it cancelsuncollectible, never finalized or charged, sendinginvoice.updatedandinvoice.marked_uncollectiblebefore eachcustomer.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 withpaidpayments cannot be voided.” -
Paying an incomplete subscription’s invoice makes it active, and a declined
trial_end=nowleaves it past due. Paying the open first invoice of anincompletesubscription, withPOST /v1/invoices/{invoice}/payor by confirming its PaymentIntent, now makes the subscriptionactiveat once, as Stripe does, and sendsinvoice_payment.paidandcustomer.subscription.updated. A card pay’s events now runcharge.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’strial_end=nowwhose charge is declined now leaves the subscriptionpast_dueinstead ofactive. A declined confirm stores the PaymentMethod it names without attaching it,payment_method_configuration_detailsnames the account’s configuration wheneverautomatic_payment_methodsis set, andPOST /v1/chargesdeclinestok_chargeCustomerFailand refuses apm_source with Stripe’s 400. -
A void or uncollectible mark of an invoice that is not open answers Stripe’s not-open message.
POST /v1/invoices/{invoice}/mark_uncollectibleof an invoice that is notopennow answers Stripe’s 400 “You can only pass in open invoices. This invoice isn’t open.” A void of apaidinvoice whoseamount_paidis 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 withpaidpayments cannot be voided.” Before,mark_uncollectibleanswered a message Stripe never sends, and a void of a paid 0.00 invoice answered the paid-invoice one. -
An out-of-band pay records its own invoice payment, and a paused update checks its booleans first. After
POST /v1/invoices/{invoice}/paywithpaid_out_of_band=true,GET /v1/invoice_payments?invoice=lists a newpaidinvoice payment oftypepayment_record, naming apr_test_payment record and no PaymentIntent, and the finalize’s invoice payment readscanceled, still naming its PaymentIntent. Neither is the default. Before, the finalize’s stayedopenand the default, and nothing else was written.POST /v1/subscriptions/{id}with acancel_at_period_endthat is neithertruenorfalseanswers Stripe’s 400 “Invalid boolean:<value>” onparamcancel_at_period_end, even on apausedsubscription, where it used to answer the paused refusal. Withbilling_cycle_anchor=nowbesidecancel_at_period_end, apausedsubscription answers the anchor’s refusal. -
Paying an invoice out of band attempts nothing and cancels its PaymentIntent.
POST /v1/invoices/{invoice}/paywithpaid_out_of_band=trueleavesattemptedandattempt_countas they were and cancels the PaymentIntent the finalize created, withcancellation_reasonduplicate. It sendsinvoice.updated,invoice.paidandpayment_intent.canceled, in that order, and noinvoice.payment_succeeded. Every pay’sinvoice.updatednow namesamount_remaininginprevious_attributes, and a card payment’s namesattempt_counttoo. Before, an out-of-band pay counted as an attempt, sentinvoice.payment_succeededand left the PaymentIntent waiting for a payment method. -
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_microdepositsandPOST /v1/setup_intents/{intent}/verify_microdepositsmatch the test codes in any case and the amounts in either order, andamounts10 and 11 exceed the attempts and 40 and 41 time out, asSM33CCandSM44DDdo. A verify sending neither input answers Stripe’s 400parameter_missingondescriptor_code, and one sending both 400parameters_exclusive. On a SetupIntent,SM33CCanswers the attempts-exceeded 400 andSM44DD200 with the SetupIntentrequires_payment_methodandlast_setup_errorsetup_intent_setup_attempt_expired, each sendingsetup_intent.setup_failedand leaving the PaymentMethod unattached, and a wrong code answers the mismatch 400 with the attempts left, ten per SetupIntent. A verifiedus_bank_accountpayment’s balance transaction reads typepaymentwith Stripe’s 0.8% ACH fee (8 on 10.00),available5 s after it is created, and its net lands in the balance’savailable. Before, a PaymentIntent verify matched the codes only in capitals, a SetupIntent’s wrong or failing verify answered 400payment_method_microdeposit_verification_amounts_invalid, and the ACH balance transaction read typechargewith the card fee,pending, and the balance took the card fee’s net intopending. -
A seller frozen before its activation activates after a re-request. A connected seller whose merchant configuration is deactivated and re-applied before
card_paymentsever activated readscard_paymentsnulluntil it re-requests it; the re-request readscard_paymentspendingagain, withstripe_balance.payoutspendingwhen the seller holds a bank account, and both readactiveonce/_rystic/clockis 85 seconds past the seller’screated, so the seller charges. The twin used to leave the re-requestedcard_paymentspendingand refuse the charge. -
A pending refund settles when the clock reaches it. A refund of a captured
pm_card_pendingRefund/tok_pendingRefundcharge, throughPOST /v1/refunds, either charge refund route or a credit note’srefund_amount, answerspendingwithpending_reasoninsufficient_fundsand a card destination that names no acquirer reference yet, as Stripe’s does, and itspendingbalance transaction can be retrieved at once. Once/_rystic/clockreaches 122 seconds after the refund’screatedit readssucceededwithoutpending_reason, and at 249 seconds its acquirer reference lands; each move sendsrefund.updatedandcharge.refund.updatednaming 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 itsGET /v1/eventsand not the platform’s. It no longer readspendingforever. Every refund read and update, the charge’s refund list included, answerspending_reasonwhile the refund is pending, and the charge’s refund list now answers each refund’sbalance_transactionanddestination_detailsas the other refund reads do. -
Products and prices announce their creates, updates and deletes.
POST /v1/productssendsproduct.created, carrying the product as the request answers it, and a create withdefault_price_datanow mints that price (named indefault_price) and sendsplan.created(for a recurring price) andprice.createdfor it first.POST /v1/pricessendsplan.createdfor a recurring price, carrying the legacy Plan view of the price under the same ID, thenprice.created; a one-time price sendsprice.createdalone. So a catalog synced from webhooks hears about new products and prices.POST /v1/products/{id}sendsproduct.updatedandPOST /v1/prices/{price}sendsplan.updated(for a recurring price) thenprice.updated, each withprevious_attributesover the fields the update changed, andDELETE /v1/products/{id}sendsproduct.deleted.GET /v2/core/events?object_id=lists them as thin events. Not yet modeled: Stripe’s refusals of a malformeddefault_price_data, and theproduct.createda price create withproduct_datasends. Before, none of these routes wrote an event, anddefault_price_datawas ignored. -
A
sepa_debitpayment processes, then settles on the clock, and aus_bank_accountone waits on microdeposits.POST /v1/payment_methodstakestype=sepa_debitwith aniban. A PaymentIntent created withconfirm=trueand asepa_debitmethod answersprocessingwith apendingcharge, and 15 s after the charge the clock settles it, sendingcharge.updated,payment_intent.succeededandcharge.succeededas Stripe does, or, for the failing test IBANDE62370400440532013001, returning the intent torequires_payment_methodwithpayment_intent.payment_failed,charge.failedandcharge.updated; a payment made acting as a connected account settles in that account, and its settle events are the account’s. Aus_bank_accountcreate-and-confirm without Financial Connections answersrequires_actionwithverify_with_microdeposits, and asepa_debitSetupIntent created withconfirm=truesucceeds at once; before, the PaymentMethod create refusedsepa_debitand each of these creates answeredrequires_payment_method. -
billing_cycle_anchor=nowrestarts the billing period. An update withbilling_cycle_anchor=nowon anactivesubscription now restarts each item’s period at the update, writes no invoice and, unlessproration_behaviorisnone, 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. -
A trial with no payment method mints a pending SetupIntent. A
charge_automaticallytrialingPOST /v1/subscriptionswhose customer has no default payment method now names a SetupIntent inpending_setup_intent,requires_payment_methodwithusageoff_session, and sendssetup_intent.createdbefore its other events; a customer with no currency takes the subscription’s, withcustomer.updated. Asend_invoicetrial, which nobody has measured, keepspending_setup_intentnull. A create judgestrial_endon its customer’s test clock, as it stamps the subscription: one at or before that clock answers Stripe’s 400 withparamtrial_end, andtrial_endbesidetrial_period_daysanswers 400 “Received both trial_end and trial_period_days parameters. Please pass in only one.” Before,pending_setup_intentwas null, a pasttrial_endstarted no trial, and the payment-method check read the twin’s clock. -
The clock’s
payment_intent.createdcarries the PaymentIntent as created. When the clock finalizes a subscription invoice and charges it,payment_intent.createdreads the PaymentIntent before the charge,requires_payment_methodwithamount_received0 and nopayment_method,latest_chargeorlast_payment_error, as Stripe sends it, where it used to carry the PaymentIntent after the charge. -
A subscription create refuses an empty
collection_methodand takes thecancel_atliterals.POST /v1/subscriptionswithcollection_methodempty answers Stripe’s 400parameter_invalid_emptynamingcollection_method, before every other check, and writes nothing. Before, it read as omitted and created the subscription. Acancel_atofmin_period_end,max_period_endormax_billed_untilsetscancel_atto the first period’s end, records the cancellation request and bills that period whole, as Stripe does. -
A subscription create refuses a malformed
collection_method,trial_endorcancel_at. Before it looks anything up,POST /v1/subscriptionsanswers Stripe’s 400 naming the parameter, checked in this order: a non-emptycollection_methodother thancharge_automaticallyorsend_invoice(“Invalid collection_method: must be one of send_invoice or charge_automatically”), atrial_endthat is neithernownor a unix time, such asabc,1.5or an RFC 3339 time (“Invalid trial_end: must be one of now”), and acancel_atthat is neither a unix time normin_period_end,max_period_endormax_billed_until(“Invalid cancel_at: must be one of min_period_end, max_period_end, or max_billed_until”); an emptytrial_endorcancel_atanswersparameter_invalid_empty, an emptycancel_ateven besidecancel_at_period_end=true. Before, these creates answered 200, ignoring the value or reading an RFC 3339 time as a timestamp. -
A subscription create takes
cancel_at.POST /v1/subscriptionsnow acceptscancel_at, recording a cancellation request:canceled_atis the create’s time andcancellation_details.reasoniscancellation_requested. Acancel_atbefore 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_endand the anchor stay and the zero line runs tocancel_at. The clock then cancels the subscription atcancel_atwith no further invoice. Acancel_atat or before the create’s clock (a test clock’sfrozen_timewhen the customer is on one) answers Stripe’s 400 “Thecancel_atparam can only be set to a date in the future.”, and one besidecancel_at_period_end=trueanswers 400 “Received both cancel_at_period_end and cancel_at parameters. Please pass in only one.” Before, the create refusedcancel_atwithparameter_unknown. -
pm_card_chargeCustomerFailattaches, and a subscription charging it on the clock goespast_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 readsfailedwithcard_declined, the PaymentIntent readsrequires_payment_methodwith the decline as itslast_payment_error,charge.failedandpayment_intent.payment_failedgo out, and the invoice stays open with one attempt and its retry dated, the subscription readspast_dueand the customerdelinquent; each retry the clock runs on that card is declined the same way. Paying the invoice with another card succeeds its PaymentIntent and clears thatlast_payment_error. -
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’snext_payment_attemptreads 42.1 h after the attempt, which puts the ninth attempt where Stripe’s fell, and a secondinvoice.updatedannounces it beforeinvoice.payment_failed. The clock then runs that retry: with no card it fails again,attempt_count2 and the next retry 42.1 h on, and the ninth attempt cancels the subscription forpayment_failed, leaving the invoice open and the customerdelinquent; with a default card that charges it pays the invoice,customer.updatedlast. Voiding the invoice, marking it uncollectible or paying it returns thepast_duesubscription toactivein that request, and each clears the customer’sdelinquentwithcustomer.updated, even while another of its subscriptions is stillpast_due; an invoice paid any other way does the same at the clock’s next settle. Ending a subscription, byDELETE, at itscancel_ator by deleting its customer, stops automatic collection of the customer’s open invoices and of the subscription’s renewal draft: each readsauto_advancefalse and no retry, withinvoice.updatednaming no request, and the draft never finalizes; deleting the customer or its test clock marks the draftuncollectibleinstead.DELETEwithinvoice_now=true&prorate=true, and acancel_atset 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’sbalance, clearingdelinquent. A trial shorter than three days sends noinvoice.upcoming. A paid invoice readsauto_advancefalse and no retry and keeps itsattempt_count, and itsinvoice.updatedholds Stripe’s six changes; the pay’s events follow Stripe’s order,customer.updatedfirst andcustomer.subscription.updatedlast.POST /v1/subscription_itemsunderproration_behavior=always_invoicebills the added item’s proration at once on asubscription_updateinvoice: paid from the card undercharge_automatically, paid with no payment when it is 0, and a draft that finalizes an hour later undersend_invoice; undercharge_automaticallywith something to bill and no stored payment method the add is refused with Stripe’s 400resource_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 sendsinvoiceitem.createdandcustomer.subscription.updated. Proration descriptions write a one-digit day as07 Nov 2026. Before,next_payment_attemptread null and nothing retried, only a paid invoice endedpast_dueand only on the next request, retries never ended, a cancel left its customer’s invoices and its renewal draft withauto_advancetrue,invoice_nowandproratebilled nothing,always_invoicewaited for the renewal, and a deleted item dropped its pending proration, credited nothing and sent no event. -
expand[]=latest_invoiceon a subscription embeds the invoice.POST /v1/subscriptions,POST /v1/subscriptions/{id},GET /v1/subscriptions/{id}andGET /v1/subscriptionsnow embedlatest_invoice,customer,default_payment_methodandlatest_invoice.customer(withdata.on the list) underexpand[]orexpand[0], as Stripe does;itemsandplancome back embedded either way. A path the subscription cannot expand, such aslatest_invoice.nonexistent, answers Stripe’s 400This property cannot be expanded, and a list path withoutdata.answers the 400 that suggests it. A refused create stores nothing. Before, these routes ignoredexpandand answered the invoice ID. -
A subscription’s
payment_intent.createdcarries the PaymentIntent as it was created. When a subscription create pays its first invoice, or an update’strial_end=nowpays the new period,payment_intent.createdreads the PaymentIntentrequires_payment_methodwithamount_received0 and a nullpayment_method,latest_chargeandlast_payment_error, as Stripe sends it before the charge. Before, it read the PaymentIntent alreadysucceeded. -
An unpaid resume invoice is voided when its pending update expires, and
customer.subscription.resumedreads as Stripe’s does. Once the clock passespending_update.expires_at, 23 hours after a resume withbilling_cycle_anchor=now, the open resume invoice is voided, its PaymentIntent canceled andpending_updatecleared, and the subscription stayspaused, sendingcustomer.subscription.pending_update_expired; aDELETEof the subscription before then voids it the same way first. When the resume invoice is paid,customer.subscription.resumednow carries the subscription as it stood before the pending update applied,trialing, as Stripe’s does, where it readactive. Before, the invoice stayed open and a later payment still resumed the subscription. -
A connected account’s subscription renews as that account. A subscription created acting as a connected account (
Stripe-AccountorStripe-Context) settles on the clock as that account: the PaymentIntent and charge paying its renewal or trial-end invoice are listed by the account’sGET /v1/payment_intentsandGET /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 toactiveonce its invoice is paid, the resume, a resume’s expiry and the cancel atcancel_at) is the account’s, as an event its own requests cause is. Before, the renewal’s payment and events went to the platform. -
A trial’s end bills only up to
cancel_at. When a trial ends on the clock and the subscription’scancel_atfalls inside the period that starts there, each item’s period now ends atcancel_atand the trial end’s invoice bills oneTime onproration 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 atcancel_ata few days later. -
A subscription update refuses a malformed
trial_endand an empty item ID.POST /v1/subscriptions/{id}with atrial_endthat is neithernownor a unix time, such asabc,1.5or an RFC 3339 time, answers Stripe’s 400 “Invalid trial_end: must be one of now” onparamtrial_end. A hash or an array gets the same answer, though Stripe’s own answer to one is unmeasured. An update withitems[i][id]empty answers 400parameter_invalid_emptynamingitems[i][id]. Neither changes the subscription. Before, both answered 200. -
A subscription update refuses a past
cancel_at, andcancel_at_period_endorbilling_cycle_anchor=nowon a paused subscription.POST /v1/subscriptions/{id}with acancel_atat or before the subscription’s clock, a test clock’sfrozen_timewhen it is on one, answers Stripe’s 400 “Thecancel_atparam can only be set to a date in the future.” On apausedsubscription,cancel_at_period_end, true or false, andbilling_cycle_anchor=noweach answer Stripe’s 400 asking you to resume it first, writing nothing and sending no event. Before, each was accepted, and a pastcancel_atorcancel_at_period_end=truehad the clock cancel the subscription at its next move. -
A test clock advance checks its time as Stripe does.
POST /v1/test_helpers/test_clocks/{id}/advancewith afrozen_timebefore 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 (acanceledorincomplete_expiredone 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.” -
A test clock advance refuses a time that is not an integer as sent.
POST /v1/test_helpers/test_clocks/{id}/advancewith afrozen_timegiven as an RFC 3339 time, a decimal such as1.5, a space-prefixed or empty value, an array (frozen_time[]) or a hash (frozen_time[a]) answers Stripe’s 400parameter_invalid_integeronfrozen_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. -
A test clock create checks its
frozen_timeas Stripe does.POST /v1/test_helpers/test_clockswith afrozen_timethat is not an integer as sent, such asabc,1.5, an RFC 3339 time, a space-prefixed or empty value, an array or a hash, answers 400parameter_invalid_integeronfrozen_time, the value echoed as Stripe renders it ({:a=>"<t>"}forfrozen_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>and0<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. -
Test clocks announce their create, advance and completion.
POST /v1/test_helpers/test_clockssendstest_helpers.test_clock.created, carrying the clock as created. An advance sendstest_helpers.test_clock.advancingat its request, carrying the clock as the advance answers it, and once it completes, every event the completion sends, thentest_helpers.test_clock.ready, carrying the clock ready at the new time and naming no request, as Stripe orders them. -
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 thecustomer.deletedof each customer on the clock, oldest first, and before thecustomer.subscription.deletedof each subscription it cancels, newest first, as Stripe orders them. -
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_missingwith Stripe’s message, “No such billingclock: ‘<id>’”, where it said “No such test clock”. -
A verified
us_bank_accountpayment settles on the clock, and the microdeposit verify answers Stripe’s test values.POST /v1/payment_intents/{intent}/verify_microdepositswithdescriptor_code=SM11AA, oramounts32 and 45, answers the intentprocessingwith apendingcharge, sendingpayment_intent.processingandcharge.pending, and 20 s later the clock settles it, sendingcharge.updated,payment_intent.succeededandcharge.succeededas Stripe does.SM33CCanswers Stripe’s 400payment_method_microdeposit_verification_attempts_exceededandSM44DDanswers the intent timed out, each failing the intent with a failed charge,payment_intent.payment_failedandcharge.failed. Any other code or amounts answers Stripe’s 400payment_method_microdeposit_verification_descriptor_code_mismatchwith the attempts left, and the tenth fails the intent. Aus_bank_accountSetupIntent created withconfirm=truenow awaits microdeposits, and its verify succeeds at once and attaches the PaymentMethod to the customer. Aus_bank_accountPaymentMethod under Stripe’s test routing number110000000readsbank_nameSTRIPE TEST BANKandstatus_details{}, and the test account000123456789Stripe’s fingerprint. Before, the verify accepted anySMcode, answeredprocessingwith no charge and no event, and nothing moved the intent after.
v0.0.16 — 2026-10-19
-
A balance with no funds reads zeros.
GET /v1/balancefor an account holding no funds answers oneusdentry at zero inavailable,pendingandrefund_and_dispute_prefunding, where a fresh connected account answered empty lists. A connected account’s balance also listsinstant_availablebesideavailable, and the platform’s listsconnect_reservedat zero, as Stripe’s do. -
Each account reads only its own balance transactions. Under
Stripe-AccountorStripe-Context,GET /v1/balance_transactionslists 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 404resource_missing“No such balance transaction”, as Stripe’s does, where it answered 200. A destination charge writes the connected account’s ownpaymenttransaction. Its source is apy_payment and its amount the transferred amount. Its fee is theapplication_fee_amount, with oneapplication_feeline infee_details. The account lists and retrieves it under its acting header. -
A Checkout Session create refuses an
expires_atoutside 30 minutes to 24 hours ahead.POST /v1/checkout/sessionstakes anexpires_atfrom 30 minutes to 24 hours after the twin clock, both ends included, and answers any other integer with Stripe’s 400invalid_request_error: a past value namesparamexpires_atand quotes the value, a nearer or later one carries noparam. No session is created. An omittedexpires_atstill defaults to 24 hours after the create. -
A Checkout Session expires when the clock reaches its
expires_at. Once/_rystic/clockreaches an open session’sexpires_at(24 hours after its create unless the create set it), the session readsexpiredwith a nullurl, the expire endpoint refuses it as not open, andcheckout.session.expiredgoes out naming no request, itscreatedat the session’sexpires_ateven when one clock move jumps past it, as Stripe expires a session nobody completed; it no longer readsopenpast its deadline. -
A PaymentIntent or SetupIntent created without payment method types needs a
return_urlat confirm. A create that names neitherpayment_method_typesnorautomatic_payment_methodsstores and answersautomatic_payment_methods{enabled: true, allow_redirects: "always"}, as one sending onlyautomatic_payment_methods[enabled]=truedoes, and its/confirmwithout areturn_urlanswers 400invalid_request_errorwith Stripe’s return_url message, the intent embedded and nocodeorparamkey. The same confirm with areturn_urlsucceeds, as does any confirm of an intent created withpayment_method_typesor withallow_redirectsnever. A SetupIntent’sautomatic_payment_methods.enabledreads as a boolean. -
A PaymentIntent created with
confirm=trueannounces its charge before the settle. Itscharge.succeededcarriesbalance_transactionnull, as a confirm through/confirmdoes, and the balance transaction arrives with the onecharge.updatedabout 3 s later on the twin’s clock, which a refund within 2.5 s of the payment drops. -
A credit note preview carries its
effective_at.GET /v1/credit_notes/previewanswerseffective_atas the preview’screatedtimestamp, or as theeffective_atthe request sends, as Stripe does, where it used to answernullunless the request set one. -
A v2 account with the customer configuration pays as
customer_account. A PaymentIntent update, a SetupIntent update and a PaymentMethod attach takecustomer_accountas the PaymentIntent create does, and each answers it beside thecus_ID of the account’s v1 customer, as do a refund of such a PaymentIntent andGET /v1/payment_intents?customer_account=. An unknown account answers 400resource_missing.GET /v1/customers/{account id}answers that v1 customer under its owncus_ID. -
customer_accountis refused as Stripe refuses it. Every operation that takescustomer_accountanswers an unknown or closed account with 400resource_missing“No such customer: ‘<id>’” onparamcustomer_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 forcustomersent besidecustomer_account, and the intents’ errors carry the unchanged intent. Thecus_ID of an account’s v1 customer reads back withcustomer_accountset, and a PaymentIntent created for it carriescustomer_account. Acting as an account, a recipient configuration counts toward the configuration check only when it requestedstripe_transfers, and the check comes before parameter validation. -
A customer create refuses a test clock that does not exist.
POST /v1/customerswith atest_clockthat never existed, or one already deleted, answers Stripe’s 400resource_missingontest_clock, “No such billingclock: ‘<id>’”, and creates no customer. Before, it created the customer on the twin’s clock. -
Deleting a customer cancels its subscriptions.
DELETE /v1/customers/{customer}cancels each of the customer’s live subscriptions at once, as Stripe does: each readscanceledwithended_atat the delete,canceled_atthere too unless a pending cancellation request keeps its own, andcancellation_details.reasoncancellation_requested, and the clock no longer renews and bills it.customer.deletednow carries the customer as it stood, not the deleted stub, and goes out before eachcustomer.subscription.deleted; deleting a test clock sends them in that order too. -
A declined charge is stored, listed and sent as
charge.failed. APOST /v1/chargesthat a declining test token refuses still answers 402. The charge itserror.chargenames can now be retrieved and is listed, readingstatus: failedwith Stripe’sfailure_code,failure_message,outcomeand the test card’slast4andfunding. A declined charge, and one a Radar block list refuses, now emitscharge.failedto your endpoints and the event logs. -
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 transferstransfer_data[amount]to the connected account when it is set, else the whole amount, and takes anyapplication_fee_amountback from it. The connected account’s balance, whichGET /v1/balancereads underStripe-AccountorStripe-Context, gains the transfer less the fee. The platform’s moves by the net less the transfer plus the fee. Sending bothtransfer_data[amount]andapplication_fee_amountanswers 400parameters_exclusive, as Stripe does. The twin moves both balances at the charge; Stripe shows them seconds later. -
An invoice can name its payer by
customer_accountalone.POST /v1/invoiceswithcustomer_accountand nocustomerchecks only the account, so an unknown or closed one answers 400resource_missingwith paramcustomer_accountand the account ID, where it answered a missing customer. -
A PaymentIntent with automatic payment methods lists Link. A create with
automatic_payment_methods[enabled]=trueand nopayment_method_typesanswerspayment_method_types["card", "link"]and alinkentry inpayment_method_options, as Stripe’s does, where it answered an empty list. -
A payout below 1 is refused with Stripe’s message.
POST /v1/payoutswith anamountof 0 or less answers 400parameter_invalid_integer“This value must be greater than or equal to 1.”, where it answered “Invalid positive integer”. -
A connected seller’s capabilities activate on the clock. A v2 account created with its onboarding details reads
card_paymentspendinguntil/_rystic/clockis 85 seconds past itscreated, thenactive, with or without a bank. Once it holds an external account, itspayoutsreadpendingfrom 18 seconds andactivefrom 85, and without one they stayrestricted. Each capability whose status moves sends itsv2.core.account[configuration.<merchant|recipient>].capability_status_updatedevent, naming no request. Deactivating a configuration sets its capabilities to null, and re-applying it leaves them null. Acard_paymentsre-requested after that readsactiveat once on an account past its activation, withstripe_balance.payoutsactivetoo when it holds a bank, so the seller charges again; on an account not yet activated it readspending. Advance the clock by 85 seconds or more, or run withRYSTIC_PACE=1, to reach activation. -
A charge as a seller is refused as Stripe refuses it. A PaymentIntent created with
confirm=trueunderStripe-AccountorStripe-Contextanswers 400 “Your account cannot currently make charges…” while the seller’scard_paymentsis pending, or when its merchant and recipient configurations were both deactivated. It answers 400 “You cannot create a charge on a connected account without thecard_paymentscapability 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 statusrequires_payment_method, beside the payment method. An unconfirmed create is not refused. APOST /v1/chargesunder either header answers the same “Your account cannot currently make charges…” 400 while the seller’scard_paymentsis not active, with no PaymentIntent and no charge in the error. -
A renewal that would run past
cancel_atbills only up to it. When the clock renews a subscription whosecancel_atfalls before the next period end, the item’s period ends atcancel_atand the renewal bills that stub as a proration, paid an hour later, before the subscription cancels atcancel_at. Before, the renewal billed the whole period. -
A scheduled cancellation keeps its request time in
canceled_at. A subscription withcancel_at_period_endorcancel_atthat the clock ends now readscanceled_atas the time of the update that asked for it,ended_atas the end, and keepscancel_atandcancel_at_period_end, as Stripe documents. ADELETEwhile such a request is pending keeps itscanceled_atand clearscancel_atandcancel_at_period_end, as Stripe does. Before,canceled_atread the end. -
A subscription create takes
trial_endandtrial_settings.POST /v1/subscriptionsnow acceptstrial_end, starting a trial that ends then and needs no payment method, andtrial_settings[end_behavior][missing_payment_method](cancel,create_invoiceorpause), whichtrial_settingsstores. Both were refused withparameter_unknownbefore. A trial’s items run from its start totrial_end, and itsbilling_cycle_anchoristrial_endunless the create sets one. -
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_itemsor an update’sitems[]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. -
A subscription keeps its last item. An update whose
items[]would leave a subscription with no item, andDELETE /v1/subscription_items/{item}on its only item, answer Stripe’s 400invalid_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. -
A subscription whose payment fails goes
past_due, andtrial_endmoves the trial. When acharge_automaticallysubscription’s invoice finalizes with no payment method, as at a trial’s end undermissing_payment_method=create_invoicewith no card, its payment fails: the invoice stays open with one attempt, the subscription readspast_dueand the customerdelinquent, andinvoice.payment_failedgoes out; paying the invoice returns both. A latertrial_endon a trialing subscription movesbilling_cycle_anchor, the period end and a period-endcancel_atwith it and bills the added days on a paid zero invoice, and one at or before the subscription’s clock answers Stripe’s 400 withparamtrial_end. A trial’s invoice lines readFree trial for <quantity> × <product>. Before, the subscription stayedactivewith the invoice open, and atrial_endupdate only stored the new time, or ended the trial when it was past. -
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 underbilling_cycle_anchor=unchangedaligns to the same days. Before, a Jan 31 anchor renewed on Mar 3 and drifted from there. -
Subscriptions bill prorations for partial periods. A create whose
billing_cycle_anchoris 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. Unlessproration_behaviorisnone, an item added to anactiveorpast_duesubscription leaves a pending proration for the rest of the period, sent asinvoiceitem.created, which the next renewal bills ahead of the new period;POST /v1/subscription_itemsnow also sendscustomer.subscription.updated. A renewal into a period shorter than the price’s interval, afterbilling_cycle_anchor=nowon an update or a clearedcancel_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. -
A resume of a subscription that is not
pausedanswers Stripe’s message.POST /v1/subscriptions/{id}/resumeon atrialing,activeor other subscription that is notpausedanswers 400invalid_request_errorwith “You can only resume a subscription if it ispaused.”, as Stripe does. Before, the message named the subscription and its status. -
A resumed subscription stays
pauseduntil its resume invoice is paid.POST /v1/subscriptions/{id}/resumewithbilling_cycle_anchor=now(the default) now answers the subscription stillpaused, with apending_updateand an opensubscription_cycleinvoice 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 readsactivefrom the resume and sendscustomer.subscription.resumed,customer.subscription.pending_update_appliedandcustomer.subscription.updated. A resume withbilling_cycle_anchor=unchangedstill readsactiveat once with no invoice. Before, every resume readactiveat once with no invoice. -
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_endgoes out, afterinvoice.upcomingwhen the end will bill. Attrial_enda subscription with a card, asend_invoiceone, or one undertrial_settings[end_behavior][missing_payment_method]=create_invoicereadsactiveand renews into asubscription_cycledraft that finalizes an hour later: asend_invoiceone is sent, a card pays acharge_automaticallyone, and with no card its payment fails and the subscription goespast_due; abilling_cycle_anchoraftertrial_endbills a proration up to it first. With no card,pausepauses it andcancelcancels it, and a later resume starts its period at the resume (withbilling_cycle_anchor=unchanged, on the anchor’s schedule), billing nothing for the paused time.trial_end=nowon an update bills the new period at once, andcancel_at_period_endthen cancels at that period’s end. Every event the clock sends, a renewal’s and acancel_at’s included, is now stamped at the instant it was due, even when one clock move jumps past it. Before, a subscription readtrialingpast itstrial_end. -
A resume that keeps the anchor bills the rest of the period.
POST /v1/subscriptions/{id}/resumewithbilling_cycle_anchor=unchangedstill readsactiveat once with no invoice, and, unlessproration_behaviorisnone, 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 sendscustomer.subscription.updatedandcustomer.subscription.resumed. Before, the paused part of the period was free and the resume sent no event. -
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 400invalid_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. -
Subscriptions on a test clock renew when the clock advances. A customer created with
test_clockand its subscriptions live on the clock’sfrozen_time: theircreated, periods, trial dates and invoices read it, and each invoice carries the clock’stest_clock. When an advance completes, every renewal, trial warning and end, draft finalize andcancel_atdue 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 notest_clock./_rystic/clockmoves 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. -
A test clock’s advance completes. After
POST /v1/test_helpers/test_clocks/{id}/advance, the next API request still reads the clockadvancingwith its oldfrozen_time, and the request after it reads the clockreadyat the requested time withstatus_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 leftadvancing. -
A v2 account’s writes emit Stripe’s account events. A create emits
v2.core.account.createdplus 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 acapability_status_updatedwithdata.updated_capabilityfor each capability status it sets; an update emitsv2.core.account.updatedand the merchant part and capability events it causes; a close emitsv2.core.account.closed.GET /v2/core/eventslists them, never/v1/events, and a part event’srelated_object.urlcarries?include=<part>, whichGET /v2/core/accounts/{id}accepts, so stripe-node’sfetchRelatedObjectresolves it. An event’schangestakes 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. -
GET /v2/core/eventsanswers 10 events a page by default. A list sent withoutlimitanswers 10 events, as Stripe’s does, and its relativenext_page_urlcarries the page token and only the parameters the request sent, so the next page reads 10 again. -
Balance settings follow the seller’s activation. Under
Stripe-AccountorStripe-Context,GETandPOST /v1/balance_settingsreadpayments.payouts.statusenabledandpayments.debit_negative_balancesfalseonce the account’sstripe_balance.payoutscapability is active, as Stripe’s do, anddisabledandtruebefore. -
Acting as a closed account is refused. A
Stripe-AccountorStripe-Contextheader naming a v2 account the key closed answers 403account_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. -
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(itstr_transfer) and, with anapplication_fee_amount,application_fee(itsfee_ID). The twin namestransferandapplication_feeat the charge, where Stripe names them about 6 seconds later. -
A destination charge lists the platform’s transfer and application fee. After a destination charge, the platform’s
GET /v1/balance_transactionslists atransferrow for minus the transferred amount, its source the charge’str_transfer, and, with anapplication_fee_amount, anapplication_feerow for plus the fee, its source afee_application fee, beside thechargerow, so the list adds up to the platform’s balance as Stripe’s does. -
A destination charge tells the connected account about its payment. The
py_payment a destination charge makes emitspayment.createdto the connected account: the account’sGET /v1/eventslists it, withdata.objectthe payment andrequest{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’senabled_eventsacceptspayment.created. Stripe lists the event about 3 s after the charge, and the twin emits it at once. -
A destination charge’s refund can reverse the transfer and the application fee. A refund with
reverse_transfer=truetakes its share of the transfer back from the connected account: the account lists apayment_refundrow and apyr_refund of its payment, the platform atransfer_refundrow, and the refund’stransfer_reversalnames atrr_reversal. Withrefund_application_fee=trueit returns its share of the application fee: the account lists anadjustmentrow and the platform anapplication_fee_refundrow. 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. Withoutrefund_application_feethe 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. -
A connected account’s events are its own. An event a request causes while acting as a connected account (
Stripe-AccountorStripe-Context) belongs to that account: its v1 snapshot carriesaccountandcontextnaming it, its v2 renderingcontext, andGET /v1/eventsandGET /v2/core/eventslist 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 withconnect=trueis a Connect endpoint: it rendersapplication(a stand-inca_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 rendersconnect. -
A payout writes its balance transaction.
POST /v1/payoutswrites apayoutbalance transaction for minus its amount, the one the payout’sbalance_transactionnames, soGET /v1/balance_transactions/{id}retrieves it and the list, filtered bytype=payoutorsourcetoo, includes it. An account’s transactions now add up to its balance after a payout. -
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 accountPOST /v1/accounts/{account}/external_accountsreturned for that account, where the twin used to name a newba_ID on each payout. The platform’s payouts all name one stand-in bank account.arrival_dateis midnight UTC of the payout’s day, as the measured payouts read, where it used to equalcreated. -
A connected account reads the payment a destination charge makes. Under
Stripe-AccountorStripe-Context, the connected account retrieves itspy_payment withGET /v1/charges/{id}and finds it inGET /v1/charges, as Stripe’s does. The payment is a Charge for the transferred amount whosesource_transfernames the platform’s transfer and whosebalance_transactionis the account’spaymentrow. -
A v2 account’s identity never echoes an ID number. A stored identity keeps only each ID number’s type,
us_ssnfollowed byus_ssn_last_4, never its value, ininclude=identityreads and in thev2.core.account[identity].updatedevent alike, and its address countries read upper-case (US), as Stripe’s do. -
include=requirementslists what an account still owes. A v2 account read, listed, created or updated withinclude=requirementsanswers{entries, summary}. An onboarded seller listsexternal_accountuntil 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 answersentries: []withsummary: null. An account not onboarded whole lists onlyexternal_account, where Stripe names each missing field.future_requirementsreadsnull. -
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 400parameter_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. -
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 inStripe-AccountorStripe-Context.
v0.0.15 — 2026-10-18
-
A v2-native event stays off
/v1/events. An event of av2.type, such as the event destination ping, is logged only as av2.core.event:GET /v1/eventsno longer lists it, its v1 ID no longer retrieves it, and no webhook endpoint receives it, as on Stripe. -
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/clockmoves the clock past a retry’s due time. Each retry carries the same body and a newStripe-Signature, soconstructEventandparseEventNotificationaccept 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. Thedeliveryreport onGET /_rystic/egressaddsretries_armed,next_retry_atandnext_retry_clock;pendingstill counts a retry waiting out its delay, so a test that waits forpendingto reach 0 after a failed delivery waits through the retries unless it moves the clock past them. -
An event’s
pending_webhookscounts the endpoints yet to take it. A v1 event’spending_webhooksis the number of enabled webhook endpoints subscribed to it when it was logged, less each that has answered a delivery with a 2xx, soGET /v1/events?delivery_success=falselists an event still waiting on a retry or whose retries all failed, where it used to read0always. Thin event destinations count nothing; withRYSTIC_WEBHOOK_DELIVERYoff, nothing lowers the count. -
A card confirm at create without a
return_urlis refused as Stripe refuses it, a charge’s balance transaction can be read, and the charge sendscharge.updated.POST /v1/payment_intentsor/v1/setup_intentswithconfirm=trueand neitherpayment_method_typesnorautomatic_payment_methodsanswers Stripe’s 400 without areturn_urland creates no intent. A succeeded charge names its balance transaction at the confirm and, about 3 s later on the twin’s clock, sends onecharge.updatedwhoseprevious_attributescarrybalance_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’samount,fee,net,typeandstatus, andGET /v1/balance_transactionslists them, filtered bysourceortype. Not yet as Stripe: a create withautomatic_payment_methods[enabled]=trueand noallow_redirects=neveris accepted without areturn_url, the/confirmroutes still accept an intent created withoutpayment_method_typesand noreturn_url, the charge’s transaction reads at once where Stripe attaches it about 3 s later, and the event’sprevious_attributescarry noreceipt_url.
v0.0.14 — 2026-10-16
-
A card can be blocked, and Radar’s default lists stay fixed. A card whose fingerprint is on the default
card_fingerprint_blocklistis refused with402 card_declined, as a blocked email is, by PaymentIntent create withconfirm=true, confirm and charge create, and pays again once the item is deleted. Each test card reads onecard.fingerprinton every path, sotok_visa,pm_card_visa, a saved card and a charge’s card agree. Renaming or deleting one of the 24 default lists answers Stripe’s400. -
Charge search filters by its query.
GET /v1/charges/searchnow returns only the charges that match the query, as Stripe does:amount,created,currency,customer,disputed,refunded,status,metadata['key'],billing_details.address.postal_codeand the card fields underpayment_method_details, joined byANDorOR. Ametadata['order_id']:'…'lookup works, and an unsupported field or operator, an unquoted string or a query that mixesANDandORanswers 400 with Stripe’s message. -
Every refund route refuses what Stripe refuses.
POST /v1/refunds,POST /v1/charges/{charge}/refundsandPOST /v1/charges/{charge}/refundnow answer the same 400s:charge_disputedfor a disputed charge,charge_already_refundedfor 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. -
GET /_rystic/egressshows the twin’s URLs. An event’shosted_invoice_url,invoice_pdfandreceipt_urlin the egress log are on the origin you read it on, as in the delivered event andGET /v1/events, never oninvoice.stripe.comorpay.stripe.com;?raw=1returns Stripe’s hosts. -
Paying an invoice charges the customer’s default card, and invoice payments are served.
POST /v1/invoices/{invoice}/paywith nopayment_methodcharges the invoice’sdefault_payment_method, else the customer’sinvoice_settings.default_payment_method, and the PaymentIntent and charge it makes name that card, so a default bypassPending card’s funds land inavailable.GET /v1/invoice_payments(filtered byinvoice,statusorpayment) andGET /v1/invoice_payments/{invoice_payment}read the payment that names the invoice’s PaymentIntent. -
v2 events and event destinations.
GET /v2/core/eventslists andGET /v2/core/events/{id}retrieves every event your requests cause, asv2.core.eventobjects under their ownevt_test_IDs, filtered byobject_id,typesorcreated, newest first and paged bynext_page_url./v2/core/event_destinationscreates, lists, retrieves, updates, deletes, enables, disables and pings destinations; withRYSTIC_WEBHOOK_DELIVERY=on, a thin destination receives each event it enables as a signed thin notification.GET /v1/eventslists 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. -
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_automaticallyand a default card it pays it at once:latest_invoiceretrieves 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=truesetscancel_atto the period end, and the subscription ends there when the clock reaches it. A created payout readspaidon every later read, as test mode does, so canceling it answers “Payouts can only be canceled while they are pending.” Events carrychangesonly where Stripe’s do, andpayout.createdand a new refund’s settlingrefund.updatedandcharge.refund.updatedname 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 sendsinvoice.updated. Event destinations answerevents_from: ["@self"], refuse an.invalidURL, an unknownsnapshot_api_versionand anincludeother thanwebhook_endpoint.url, and an unknown v2 event answers “No such event: ’…’”. -
stripe-node pages and filters v2 events.
next_page_urlandprevious_page_urlonGET /v2/core/eventsandGET /v2/core/event_destinationsare relative paths, as Stripe answers them, so an SDK’s autopaging follows them; the indexedtypes[0]=…&types[1]=…the SDK sends filters as Stripe does; and an event’srequest.idnames the answer’sRequest-Idwhen the request carries anIdempotency-Key. -
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. -
Webhook endpoints and event destinations are one list, and late events arrive late. A v2 snapshot destination lists in
GET /v1/webhook_endpointsand a v1 endpoint in/v2/core/event_destinations, and a delete on either route removes both.charge.updatedfollows a confirmed charge about 3 s later, unless the charge is refunded within 2.5 s of the confirm, which drops it for good, andpayout.createdlands after the payout’s updates, as Stripe sends them. A destination’s signing secret can no longer be derived from its ID. -
Subscriptions, items, refunds and charges carry every member Stripe returns. A subscription now has
planandquantityon every list and retrieve, even when seeded without them, and hastest_clock, aflexiblebilling_modeby default and the other members Stripe answers; an item hasplanandcurrent_trialand nobilled_until; a refund names itscustomerandpayment_methodand omits an unsetdescription; a manual-capture charge carriescapture_before. A charge no longer carriesamount_updates, which Stripe does not return. A Checkout Session created without areturn_urlomits the key, an updated invoice answerspaymentsonly when expanded, and a seeded payment link’s list and update answers no longer carry the unexpandedline_itemssublist. -
Refusals read as Stripe’s. An undeclared query or body parameter answers 400
parameter_unknownon every route (invalid_fields“Unknown field.” under/v2), and a refusal on a PaymentIntent or SetupIntent route carries the intent it names. A wrongContent-Typeon/v2answers 415. Card declines use Stripe’s current wording, a too-long metadata value is quoted asxxxx...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 theirinvalid_fields. -
A subscription without a card is refused or incomplete, and periods renew. A
charge_automaticallycreate with no card answers 400resource_missing, orincompleteunderpayment_behavior=default_incomplete. Asend_invoicesubscription’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
-
A credit grant writes its credit balance transaction. Creating a credit grant appends one
billing.credit_balance_transactionoftype: creditwithcredit.type: credits_granted, the grant’s amount, its ID ascredit_grantand itseffective_at, as Stripe does.GET /v1/billing/credit_balance_transactions?customer=…lists it andGET /v1/billing/credit_balance_transactions/{id}returns it. Voiding or expiring a grant still appends no transaction. -
The balance moves with your money, payouts draw on it, and refunds of uncaptured charges release them.
GET /v1/balanceanswers ausdentry inavailableandpendingfrom the first request. A captured card payment adds its amount less Stripe’s 2.9% + 30¢ fee, toavailablefor thetok_bypassPending/pm_card_bypassPendingtest card and topendingfor every other card; a PaymentMethod made fromtok_bypassPendingis the card ending 0077, and once saved it still settles toavailableon 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’srefund_amountdebits it as a refund does, and a payout draws onavailable: a payout above it, or in a currency the balance does not hold, answers 400balance_insufficient, and canceling a pending payout returns its funds. Refunding a charge created withcapture=falsereleases the authorization, as Stripe does, and capturing it afterwards answerscharge_already_refunded. A charge a PaymentIntent holds for capture is refused with Stripe’s message to cancel the PaymentIntent instead. Apm_card_pendingRefundpayment refundspending, and canceling a card refund answers Stripe’s “Canceling this refund is unsupported.” -
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 formcurl -u sk_test_…:now answers the invoice, invoice item, payout, product and credit balance transaction lists, which refused it with 401. -
Charges carry
amount_updates. Every charge the twin answers, on create, retrieve, update, capture, refund, list and search, holdsamount_updates: [], as a Stripe test-mode charge does under2026-06-24.dahlia, so code that reads the field no longer finds it missing. -
A chargeback can be looked up. A charge paid with
tok_createDispute, or a PaymentIntent paid withpm_card_createDispute, opens aneeds_responseDispute as Stripe does: the charge readsdisputed: trueand names it indispute,charge.dispute.createdis sent, andGET /v1/disputes,GET /v1/disputes/{dispute}andGET /v1/charges/{charge}/disputereturn it.POST /_disputestores 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”. -
Hosted invoice, receipt and payment link URLs lead to the twin. A finalized invoice’s
hosted_invoice_urlandinvoice_pdf, a credit note’spdf, a charge’sreceipt_urland a payment link’surlare on the origin you called, as a Checkout Session’surlalready 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. -
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
invoiceset to a draft becomes one of its lines, so the draft’ssubtotal,totalandamount_dueinclude it and finalize answersopenwith that amount due; updating or deleting the item moves or drops its line, andpending_invoice_items_behavior=includepulls the customer’s pending items onto a new draft. An item priced throughprice_datacostsunit_amounttimesquantity. Paying an open invoice chargespayment_method, else the invoice’sdefault_payment_method, through a succeeded PaymentIntent and charge, andexpand[]=paymentson the invoice lists theinvoice_paymentnaming that PaymentIntent. Invoice search takes single-quoted values such ascustomer:'cus_…' AND status:'paid'. On a paid invoice a credit note’srefund_amountrefunds the invoice’s charge and is listed under itsrefunds,credit_amountlowers the customer’sbalanceand names acustomer_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. -
pm_card_visabecomes a saved card. Attachingpm_card_visaor another test payment method to a customer saves a newpm_…card of that brand to them, and a declining one (pm_card_chargeDeclined, …) answers402 card_erroras Stripe does. A PaymentIntent that names a test payment method now names a newpm_…instead of the token, and a payment confirmed with a customer andsetup_future_usagesaves that card, so the customer’s payment-method list returns it. Confirm takessetup_future_usage.
v0.0.12 — 2026-10-14
-
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 namesPOST /_dispute, the world action that opens a dispute on a charge. -
The state UI is included on Free.
/_rystic/uiis available on every plan, including Free and the no-signup trial. The Pro-only verified UI download is a separate feature. -
A JSON body is refused, as Stripe refuses it. A
POSTwhose nonempty body is sent under anyContent-Typeother thanapplication/x-www-form-urlencodednow gets Stripe’s 400invalid_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 bodilessPOSTsuch as/cancelis unaffected. -
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. -
A paced clock keeps wall time, and
rystic run --speed 0stops it. UnderRYSTIC_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 runhands the twin only the pace it resolved, so--speed 0, a world without events,--tapeand--livekeep the clock manual even when your shell exportsRYSTIC_PACE. -
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_balanceandverify_microdepositsand SetupIntentverify_microdepositsactions. Error bodies from API routes carryrequest_log_url, anddoc_urlwherever they carry acode, as Stripe’s do; a 401, a refused non-form body and an unknown URL answer without them. -
Billing alerts answer
filters: [], and seeded recurring prices read back whole. A billing alert created withoutusage_threshold[filters]now answersusage_threshold.filtersas[], as Stripe does, on create, retrieve, list, activate, deactivate and archive. The setup’s recurring prices now carrytype: recurringandrecurring.interval_count,usage_typeandmeter, so a subscription’s embedded price matches Stripe’s. -
Lists refuse cursors and clamp
limitas Stripe does. The billing alerts, credit grants, meters, products and test clocks lists refusestarting_aftersent withending_before. Credit grants, meters, products and test clocks also refuse astarting_afterthat names no record, and products refuses such anending_before. The alerts, meters, portal configurations and products lists servelimit=0andlimit=101, with the page size clamped to 1..100, and refuse only a non-integerlimit. -
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_errorwith coderesource_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 thecustomerbefore the path record. Twenty-five list and search GETs refuse a query parameter they do not take with 400parameter_unknown, and the customers list takesexpandandtest_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. -
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 defaultemail_blocklistlist refuses a charge, a PaymentIntent confirm or a PaymentIntent create withconfirm=truethat sends it asreceipt_email, with Stripe’s 402card_declinedand a failed charge whose outcome names the block list. A Checkout Session’s retrieve answerscustom_fields,custom_textandshipping_options, as its list does.
v0.0.11 — 2026-10-12
-
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. -
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 andsubscription_data[metadata], completes the session and redirects tosuccess_url, sendingcustomer.created,customer.subscription.createdandcheckout.session.completedto your registered endpoints in that order. Payment and setup sessions still answer 501. -
A subscription ends at its
cancel_at. Once the twin’s clock reaches a subscription’scancel_at(set through the API or the customer portal), the subscription readscanceledwithended_atset andcustomer.subscription.deletedgoes to your registered endpoints, so aPOST /_rystic/clockadvance past it plays out the cancellation.
v0.0.10 — 2026-10-12
-
The scripts in this archive are MIT-licensed.
check.shand 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.txtno longer covers them; it still governs the twin itself. -
Webhook endpoints can receive their events. Boot with
RYSTIC_WEBHOOK_DELIVERY=onand every event your requests trigger is POSTed to each enabled endpoint registered throughPOST /v1/webhook_endpointswhoseenabled_eventsnames it or*, signed with that endpoint’ssecretinStripe-Signatureat wall-clock time sostripe.webhooks.constructEventaccepts it however the twin’s clock is pinned; without the variable nothing is POSTed. -
A world can carry its own timeline. A
--seedfile, or a complete world document posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: 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-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<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. -
A Checkout Session’s url opens the twin’s hosted Checkout page.
urlnow leads back to the twin instead ofcheckout.stripe.com, and the page shows the session’s line items, total and email with a link back tocancel_url. Submitting it is not simulated yet: it answers 501 and the session stays open. -
GET /v1/eventslists 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, soGET /v1/eventsandGET /v1/events/{id}return it beside any seeded events. -
A world can open a dispute.
POST /_disputeopens a dispute on a captured charge, the way a cardholder’s bank does: the charge readsdisputed, a refund is refused withcharge_disputed, andcharge.dispute.createdreaches the egress log and every endpoint registered for it. A world’s timeline schedules it like any other call. -
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,.updatedand.deleted. A portal session’s url leads back to the twin’s customer portal (item 12). -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, 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. -
Webhook signatures can follow a shifted receiver clock.
POST /_rystic/clock {"wall_offset_seconds": N}signs every webhook delivery’sStripe-Signaturet=N seconds past the wall clock, so a receiver whose own clock you moved forward (assandbox clock -advancedoes) still accepts it;0or/_rystic/resetputs it back. -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; 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 inbody_text. Abodymay 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. -
A customer portal session’s url opens the twin’s customer portal.
urlleads 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_atset,cancel_at_period_endleftfalse, as Stripe’s portal does) and taking a cancellation back each update the subscription and sendcustomer.subscription.updatedto your registered webhook endpoints.
v0.0.9 — 2026-10-11
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. -
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. -
Line items are built from the request’s
line_items.GET /v1/checkout/sessions/{session}/line_itemsandGET /v1/payment_links/{payment_link}/line_itemslist one line item perline_itemselement of the create, in request order, each with its ownli_ID, the element’s quantity, the product name as its description, a Price (minted fromprice_data, inactive and one-time), andamount_subtotalequal 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 (usdwhen 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 IDli_unknownor amounts of 0 on a line built fromprice_data, and neither list answers a Price that is only an ID. -
check.shgives 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
-
Charge lists include the full charge details.
GET /v1/chargesnow 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. -
Retries reuse the original response. Repeating a POST with the same
Idempotency-Keyand parameters returns the original response without creating another object. Reusing the key with different parameters returns400 idempotency_error. -
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.
-
Stripe-compatible errors and response headers. Unknown URLs and trailing slashes now return Stripe’s invalid-request envelope. Supported routes include
Request-IdandStripe-Version, and echo a pinnedStripe-Versionrequest header. -
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.
-
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. -
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.
-
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.
-
The state UI shows the account as the Stripe Dashboard. On Pro and Enterprise,
/_rystic/uiopens 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
- The guide starts from an API key and says what each plan reaches.
HOWTO.mdnow opens withrystic 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.shfinds the API keyrystic loginstored as well as a license file, looked up in the CLI’s order, andrystic featuresmarks the features and commands that need Pro or Enterprise.
v0.0.6 — 2026-09-25
-
A new world,
design_studio, now the default.--scenario design_studioboots 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 ofcustomer_with_card. -
Only
design_studioships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by--scenario,RYSTIC_SCENARIO, orPOST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks fromsim/testdata/scenarios/through the control plane. -
Self-serve on the metered edition replaces the free edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker 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
- 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
- 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
-
A fourteenth named world,
busy_account_xxl.busy_account_xlproves 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 completedmode=subscriptionCheckout 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, orcurl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xxl"}'. -
busy_account_xlis 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=100call per collection and never touchhas_moreorstarting_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 answershas_more: trueatlimit=100and 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. -
A charged-back charge can no longer be refunded.
POST /v1/refundsagainst a charge whosedisputedis true minted a refund and returned 200, where Stripe answers 400charge_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 apayment_intent-targeted one alike, and in the same order live does: an unknown parameter or a non-positiveamountis still rejected first, and the refusal precedes the amount-exceeds-remaining check.busy_account’sch_busy_disputedis the charge to try it on.
v0.0.2 — 2026-09-12
-
Separate authorization and capture works end to end.
POST /v1/payment_intents/{intent}/captureis routed, and confirming an intent created withcapture_method=manualnow holds the authorization atrequires_captureinstead of settling it. Partial captures release the remainder, over-captures are refused withamount_too_large, and the charge goes from uncaptured to captured in place — the flow acapture-laterintegration actually tests. -
confirm=trueon create does what the docs show. Creating a PaymentIntent with a payment method andconfirm=true— the one-call form the Stripe docs use for off-session charges — now succeeds and mints the charge, instead of answeringrequires_payment_methodas though the card had been declined. Creating with a payment method and noconfirmcorrectly rests atrequires_confirmation. -
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.
-
disabled=truedisables a webhook endpoint.POST /v1/webhook_endpoints/{id}withdisabled=trueflipsstatustodisabled; it was accepted and ignored. -
A thirteenth named world,
busy_account_xl.busy_accountis 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_xlis the same account at 201 customers: the wholebusy_accountcast 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 answershas_more: trueon 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, orcurl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xl"}'.
v0.0.1 — 2026-09-11
The first release of the Stripe twin.
-
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_baseat 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. -
It never reaches the network, and it needs no key. Any
Authorizationheader is accepted, and the twin dials nothing — which is the point: a suite that hitsapi.stripe.comfails when Stripe is slow, and a suite on a shared test-mode account fails when a colleague clears it. -
Seed the world your test needs.
POST /_rystic/seedtakes 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/registerslists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped. -
The same seed twice is the same world twice.
POST /_rystic/resetempties the twin andPOST /_rystic/seed-rngpins the generator, so IDs repeat run to run;POST /_rystic/clockfixes the twin’s notion of now, socreatedon every record is a value your test chose. Two identical seed flows leave byte-identical/_rystic/inspectdumps — which is what makes a failing test reproducible rather than merely re-runnable. -
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 runningstripe listen. -
See what the twin holds.
/_rystic/uiserves 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.