Simulators
Stripe simulator
stripe-twin is a local, stateful simulator of the Stripe API — real lifecycle, conflict and error paths, not stubs.
Quickstart
No stripe-twin build has been cut yet. Request access for the first one.
curl -fsSL "https://www.rystic.ai/api/install?l=…" | sh # CLI + license
rystic run stripe-twin # pulls it, serves :8080
rystic run stripe-twin -d --port 9000 --seed scenario.json
rystic run stripe-twin --runner docker -d
Point the SDK at it — base URL only, code unchanged:
stripe.api_key = "sk_test_twin"
stripe.api_base = "http://localhost:8080"
const stripe = require("stripe")("sk_test_twin", {
host: "localhost", port: 8080, protocol: "http",
});
Coverage
49 routes, 11 resources.
| Resource | Operations |
|---|---|
| Customers | create, retrieve, update, delete, list |
| PaymentIntents | create, retrieve, update, confirm, cancel, list |
| Charges | create, retrieve, update, list |
| Refunds | create, retrieve, update, list |
| PaymentMethods | create, retrieve, update, attach, detach |
| SetupIntents | create, retrieve, update, confirm, cancel, list |
| SetupAttempts | list |
| Checkout Sessions | create, retrieve, update, expire, list, line items |
| Payment Links | create, retrieve, update, list, line items |
| WebhookEndpoints | create, retrieve, update, delete, list |
| Events | retrieve, list — routed, but always empty; see Limitations |
Products, Prices and Subscriptions are the stripe-products and stripe-subscriptions simulators. No invoices surface in any of the three yet.
Auth
Any Authorization header works. Use a placeholder sk_test_... so your SDK sends well-formed requests.
Seeding state
| Endpoint | What it does |
|---|---|
GET /_rystic/version | health check + baked model hash |
POST /_rystic/reset | wipe all state |
POST /_rystic/seed | load a state snapshot |
POST /_rystic/clock | set or advance the clock |
POST /_rystic/seed-rng | fix the RNG |
GET /_rystic/inspect | dump the entire state |
GET /_rystic/egress | webhook events your requests triggered, in order — ?tail=N for the last N |
POST /_rystic/egress-retain | bound the egress log (0 = unbounded) |
GET /_rystic/registers | list seedable families and fields |
One register per resource — state_customer, state_charge, state_payment_intent, … Two seed shapes:
-
Scenario file, booted via
RYSTIC_SEED— aseedwrapper plus determinism knobs.{ "seed": { "state_customer": { "cus_dev": { "id": "cus_dev", "object": "customer", "email": "dev@example.com" } } }, "clock": "2026-07-23T00:00:00Z", "rng_seed": 42 } -
POST /_rystic/seed— the register map only, no wrapper.jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
Success returns 204; rejection names every valid register. unknown register "seed" means you posted the wrapped file.
Egress log
Nothing is POSTed to an endpoint. Every event your requests trigger renders into one ordered log, and GET /_rystic/egress returns it. This is the webhook surface.
{"events":[{"seq":1,"effect":"eff_evt_customer_created","type":"customer.created",
"payload":{"id":"evt_lllllllllllllc","object":"event","type":"customer.created",
"created":1767225602,"data":{"object":{"id":"cus_lllllllllllllc",
"object":"customer","email":"dev@example.com"}}}}],
"total":1,"dropped":0}
payloadis the envelope Stripe would have POSTed,data.objectthe resource as the API returned it.effectis the model’s id for the effect.- One call can emit several, in emission order:
POST /v1/refundsrendersrefund.created,charge.refunded,refund.updated,charge.refund.updated. totalcounts everything emitted since the last reset;eventsmay hold fewer.- The log keeps 100,000 events by default. Beyond that the oldest drop and
droppedcounts them.RYSTIC_EGRESS_RETAIN=0at boot orPOST /_rystic/egress-retain {"retain":0}removes the bound, andGET /_rystic/versionreportsegress_retain. Checkdroppedbefore trusting the log as complete. POST /_rystic/resetclears it and restartsseqat 1.
Determinism & reset
curl -X POST localhost:8080/_rystic/reset
jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
# ... run your payment flow ...
curl -s localhost:8080/v1/charges/ch_1 # assert exact records
curl -s localhost:8080/_rystic/egress # assert exact events
Execution is serialized, ids are monotonic sequences reset with the state, timestamps come from the pinned clock — which runs forward from its pin, so compare time-bearing fields by shape, not byte-equality. State is in-memory: restart = clean slate (plus RYSTIC_SEED).
Limitations
- No webhook delivery, and no usable
/v1/events. Endpoint CRUD is modeled, and the events your requests trigger render to an ordered egress log instead of being delivered.GET /_rystic/egressis the only place they appear —GET /v1/eventsreturns an emptydataarray andGET /v1/events/{id}404s. Don’t build an event-polling or webhook-replay test against/v1/events. - Nothing moves on its own. No async transitions or scheduled expiry — drive time via
/_rystic/clock. - Payments core only. No money movement or payouts; billing lives in the sibling simulators.
- Test-mode semantics. Converged against
api.stripe.comtest mode; live-only behavior is out of scope. - One process = one account world. Run multiple ports for isolation.
Cookbook
Charge, refund, assert
curl -s localhost:8080/v1/charges -u sk_test_twin: \
-d amount=2000 -d currency=usd -d source=tok_visa
# -> {"id":"ch_000000000000000000000001","amount":2000,...}
curl -s localhost:8080/v1/refunds -u sk_test_twin: -d charge=ch_000000000000000000000001
curl -s localhost:8080/v1/charges/ch_000000000000000000000001 -u sk_test_twin:
# -> "refunded": true, "amount_refunded": 2000
curl -s 'localhost:8080/_rystic/egress?tail=5'
# -> the charge.refunded event — this, not /v1/events, is where events live
Seed a returning customer
curl -X POST localhost:8080/_rystic/seed -d '{
"state_customer": { "cus_dev": { "id": "cus_dev", "object": "customer",
"email": "dev@example.com" } } }'
curl -s localhost:8080/v1/customers/cus_dev -u sk_test_twin:
# -> {"id":"cus_dev","email":"dev@example.com",...}
Deterministic ids in CI
curl -X POST localhost:8080/_rystic/reset
curl -X POST localhost:8080/_rystic/clock -d '{"time":"2026-07-23T00:00:00Z"}'
# ... run your payment flow ...
curl -s localhost:8080/_rystic/inspect > state.json
# diff state.json against the last green run — ids and amounts are exact;
# normalize created/expires_at first, they advance from the pinned clock
Release notes
Newest first. GET /_rystic/version reports which build you’re running.
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 unchanged; the SDKs need no patching. Nothing you send is billed, rate-limited, visible in a dashboard, or shared with whoever else is on the same test-mode account. - It never reaches the network, and it needs no key — any
Authorizationheader is accepted and the twin dials nothing. A suite that hitsapi.stripe.comfails when Stripe is slow; one on a shared test-mode account fails when a colleague clears it. - Seed the world your test needs —
POST /_rystic/seedlands a register map directly, so a test starts from the records it needs instead of creating them call by call.GET /_rystic/registerslists what this twin accepts; a register the model doesn’t carry is refused, never silently dropped. - The same seed twice is the same world twice —
POST /_rystic/resetempties the twin,POST /_rystic/seed-rngpins the generator so ids repeat run to run, andPOST /_rystic/clockfixes now socreatedis a value your test chose. Two identical flows leave byte-identical/_rystic/inspectdumps. - 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 it 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.
What is measured. Fidelity is scored differentially against real api.stripe.com in test mode, per response field: 131 of 161 probes on 2026-09-04, with the suite since grown to 175. Known shortfalls are in HOWTO.md under Limitations — the error envelope omits doc_url and request_log_url, DELETE /v1/customers acknowledges without performing, and a few objects serialize differently across endpoints.
Not in this release. Subscriptions, products and prices are modelled in separate twins; Connect, Tax and Billing are not modelled at all.