Skip to content

@guestcraft/stripe-simulator

MIT, stateful simulator of the Stripe API endpoints GuestCraft uses, for tests and local development. It is written from Stripe’s published OpenAPI (github.com/stripe/openapi, openapi/spec3.json, API version 2026-09-30.endive, commit 51610f5f061c9723730582a7638b8cc0e646640d) and the Stripe documentation, and imports no other workspace package, so it shares no assumption with the adapter it tests. It is not a complete Stripe server, and passing a simulator test does not prove that a live Stripe account behaves the same way.

createStripeSimulator({ now?, newId?, apiKeys?, accountId?, country?, defaultCurrency?, captureWindowDays? }) returns an empty account with baseUrl (https://stripe.simulator/v1), an injectable fetch and handle(request, signal?). Inject time and identifiers for repeatable scenarios. The default keys are rk_test_simulator and sk_test_simulator; a key { key, permissions } is a restricted key with per-resource none/read/write permissions (unlisted resources are none).

@guestcraft/stripe-simulator/server exposes startStripeSimulatorServer(simulator, { port?, host? }?). It listens on a free port of 127.0.0.1 by default and returns baseUrl (ending in /v1), origin and close(). The API, the hosted Checkout pages (/pay/{session}) and the control plane (/_simulator/..., JSON, no authentication) share that origin.

The control plane, in process:

Method Effect
completeCheckout(session, { card?, email? }) The guest pays or saves a test card on the hosted page
createPaymentMethod({ card?, livemode? }) A card PaymentMethod of the account saved to no customer, as another integration creates it with POST /v1/payment_methods (for example Channex’s Stripe tokenization app moving an OTA booking’s card); POST /_simulator/payment_methods with { card?, livemode? }
advance(ms | { days, hours, minutes, seconds }) Moves the clock; expires sessions and authorizations whose time passed
openDispute(paymentIntent, { amount?, reason? }), closeDispute(dispute, "won" | "lost") Chargeback lifecycle
nextRefund({ status: "pending" }), settleRefund(refund, "succeeded" | "failed", { failureReason? }) Asynchronous refunds
registerWebhookEndpoint({ url, secret?, enabledEvents?, livemode?, fetch? }) An endpoint configured in the Dashboard
holdDeliveries(), releaseDeliveries({ order?, duplicate? }), dropNext(type), flush(), redeliver(event, { secret?, timestamp?, endpointId? }) Delivery order, duplicates, losses and signature tests
injectFault(fault, { method?, path? }) Status, network failure, lost response after apply, hang until abort, malformed response
sessions(), paymentIntents(), paymentMethods(), charges(), refunds(), disputes(), events(), deliveries(), requests() Copies of the state

Test cards (card): visa_success, mastercard_success, decline, insufficient_funds, requires_authentication_later (4000 0027 6000 3184: authenticates on the hosted page, then fails off-session with authentication_required) and charge_declined_later (4000 0000 0000 0341: saves, then every charge is declined). Responses expose brand, last four digits, expiry, funding and country, never a card number.

Area Supported behavior
Requests Form-encoded bodies and queries with bracket notation; unknown parameters refused with parameter_unknown; expand[] up to four levels; Stripe-Version echoed; Request-Id on every answer
Keys Bearer or Basic authentication; 401 for a missing or unknown key; livemode from the key prefix; sandbox and live objects kept apart; restricted keys answer 403 without the permission
Idempotency Every POST: saved status and body replayed, different parameters refused with idempotency_error, validation failures not saved, injected 500s saved, keys pruned after 24 hours
Account and customers GET /v1/account, POST /v1/customers, GET /v1/customers (filtered by created and email, cursors), GET /v1/customers/{customer}
Checkout payment and setup sessions from price_data line items, expires_at 30 min–24 h, list filters and cursors, expire, {CHECKOUT_SESSION_ID} in success_url; intents created on completion; a declined card leaves the session open
Payment intents Create and confirm, off-session charges of saved cards and of methods saved to no customer (without customer), list filters customer and created with cursors, 402 card errors with last_payment_error, requires_action for on-session authentication, manual capture with partial capture releasing the rest, cancel, holds expiring at capture_before (7 days, or 4 days 18 hours for off-session Visa)
Refunds Several partial refunds up to the captured amount, pending refunds reserving their amount, settlement to succeeded or failed, charge.amount_refunded and refunded
Disputes Open with funds withdrawn, refunds blocked while open, pending refunds failing, won (funds reinstated) or lost
Money Amounts in Stripe’s denomination: zero-decimal currencies, ISK/UGX whole-unit two-decimal amounts, the card presentment list (KWD is refused), eight-digit maximum
Events and webhooks 16 event types at their transitions; deliveries signed t=…,v1=… (plus the fake v0 for test events), pending_webhooks, request.id/idempotency_key, holds, reordering, duplicates, drops and re-signing
Faults Status, network failure, lost response after apply, hang until abort, malformed response

Prices, Products, subscriptions, invoices, Connect, payment method types other than cards, the Confirm, Update and Attach endpoints, SetupIntent creation through the API, minimum charge amounts, multicapture, overcapture and extended authorizations, test clocks (the test_clock list filter), refund requires_action, dispute evidence and inquiries (warning_*), automatic webhook retries (use redeliver), webhook endpoint administration through the API, and balance transactions beyond those of a dispute. Simulator choices where the sources are silent: a PaymentMethod saved to no customer can be charged again (neither the spec nor the API reference documents a refusal or its error); an expired authorization gets cancellation_reason: "automatic"; releasing an uncaptured charge marks it refunded without a Refund object (Stripe may also record one with reason expired_uncaptured_charge); a partial capture releases the rest without counting it as refunded; disputes are due after 7 days.

Every behavior cites its source next to the code: an operation and schema of the pinned spec, or a docs.stripe.com page for what the spec does not state (webhook signatures, idempotency, holds, refunds, disputes, keys, currencies, test cards). Where they are silent the code says so. The simulator is never changed to suit a client; a change needs a citation.

src/openapi.snapshot.json is a reduced copy of the pinned spec: the 20 operations above with their parameters, form bodies and responses, and the component schemas they reach (other API resources and payment method types are pruned to plain objects). It records the source, commit, API version, the SHA-256 of the full file and the capture date. Stripe’s OpenAPI repository is MIT licensed, Copyright (c) Stripe, Inc. (see REUSE.toml). Refresh it by downloading the pinned spec3.json to .cache/stripe/spec3.json and running pnpm stripe:snapshot. Runtime code never reads it; src/contract.unit.test.ts validates every operation’s success and error responses, every event’s object and every recorded request’s parameters against it, rejecting undocumented fields.

Terminal window
pnpm --filter @guestcraft/stripe-simulator test
pnpm --filter @guestcraft/stripe-simulator typecheck