Skip to Content
SimulatorsStripe

Simulators

Stripe simulator

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.

ResourceOperations
Customerscreate, retrieve, update, delete, list
PaymentIntentscreate, retrieve, update, confirm, cancel, list
Chargescreate, retrieve, update, list
Refundscreate, retrieve, update, list
PaymentMethodscreate, retrieve, update, attach, detach
SetupIntentscreate, retrieve, update, confirm, cancel, list
SetupAttemptslist
Checkout Sessionscreate, retrieve, update, expire, list, line items
Payment Linkscreate, retrieve, update, list, line items
WebhookEndpointscreate, retrieve, update, delete, list
Eventsretrieve, 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
EndpointWhat it does
GET /_rystic/versionhealth check + baked model hash
POST /_rystic/resetwipe all state
POST /_rystic/seedload a state snapshot
POST /_rystic/clockset or advance the clock
POST /_rystic/seed-rngfix the RNG
GET /_rystic/inspectdump the entire state
GET /_rystic/egresswebhook events your requests triggered, in order — ?tail=N for the last N
POST /_rystic/egress-retainbound the egress log (0 = unbounded)
GET /_rystic/registerslist seedable families and fields

One register per resource — state_customer, state_charge, state_payment_intent, … Two seed shapes:

  1. Scenario file, booted via RYSTIC_SEED — a seed wrapper 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
    }
    
  2. 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}
  1. payload is the envelope Stripe would have POSTed, data.object the resource as the API returned it. effect is the model’s id for the effect.
  2. One call can emit several, in emission order: POST /v1/refunds renders refund.created, charge.refunded, refund.updated, charge.refund.updated.
  3. total counts everything emitted since the last reset; events may hold fewer.
  4. The log keeps 100,000 events by default. Beyond that the oldest drop and dropped counts them. RYSTIC_EGRESS_RETAIN=0 at boot or POST /_rystic/egress-retain {"retain":0} removes the bound, and GET /_rystic/version reports egress_retain. Check dropped before trusting the log as complete.
  5. POST /_rystic/reset clears it and restarts seq at 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
  1. 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/egress is the only place they appearGET /v1/events returns an empty data array and GET /v1/events/{id} 404s. Don’t build an event-polling or webhook-replay test against /v1/events.
  2. Nothing moves on its own. No async transitions or scheduled expiry — drive time via /_rystic/clock.
  3. Payments core only. No money movement or payouts; billing lives in the sibling simulators.
  4. Test-mode semantics. Converged against api.stripe.com test mode; live-only behavior is out of scope.
  5. 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.

  1. A local Stripe for your integration tests — 49 routes across 11 resources: customers, payment intents, setup intents, payment methods, charges, refunds, checkout sessions, payment links, webhook endpoints and events. Point your Stripe client’s api_base at the twin and your existing suite runs 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.
  2. It never reaches the network, and it needs no key — any Authorization header is accepted and the twin dials nothing. A suite that hits api.stripe.com fails when Stripe is slow; one on a shared test-mode account fails when a colleague clears it.
  3. Seed the world your test needsPOST /_rystic/seed lands a register map directly, so a test starts from the records it needs instead of creating them call by call. GET /_rystic/registers lists what this twin accepts; a register the model doesn’t carry is refused, never silently dropped.
  4. The same seed twice is the same world twicePOST /_rystic/reset empties the twin, POST /_rystic/seed-rng pins the generator so ids repeat run to run, and POST /_rystic/clock fixes now so created is a value your test chose. Two identical flows leave byte-identical /_rystic/inspect dumps.
  5. Read back the webhooks you would have sent — event and webhook egress is rendered into an ordered in-memory log (GET /_rystic/egress) rather than delivered, so you can assert on it without standing up a receiver or running stripe listen.
  6. See what the twin holds/_rystic/ui serves a live view of the registers and the requests hitting them.

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.

Last updated on