Stateful, in-process mock of the Stripe API for test suites: accounts
chosen by API key, customers and balances, payment methods, payment and setup intents, charges,
refunds, disputes, checkout (with a hosted page and a Stripe.js stand-in), invoices, subscriptions
that renew when the clock moves, subscription schedules, coupons, promotion codes, products,
prices, test clocks, webhook endpoints, the balance ledger and the event log β with signed webhooks
fanned out to every matching endpoint. Responses are rendered at the caller's Stripe-Version
(2024-06-20, 2025-02-24.acacia, or the vendored latest), and the whole surface is verified by
differential property tests against Stripe test mode.
- Operation coverage (111 of 115 operations in the vendored spec, with reasons for each gap): SUPPORT.md
- Stripe API reference: https://docs.stripe.com/api Β· Upstream OpenAPI: https://github.com/stripe/openapi
Install
npm install -D @crvouga/mockingbird-service-stripeESM only. Requires Node >= 22 or Bun >= 1.2. No native dependencies: state lives in an in-memory SQLite engine (pure TypeScript, bundled in).
Usage
npx mockingbird-stripe serve # http://127.0.0.1:12111
npx mockingbird-stripe serve --accounts accounts.json --admin-key local-admin
npx mockingbird-stripe serve --config mockingbird.json # every service in one processimport Stripe from "stripe"
import { createServer } from "@crvouga/mockingbird-service-stripe/server"
const server = await createServer({
accounts: [
// Legacy STRIPE_API_KEY, STRIPE_MSO_API_KEY and the EMR key all act as MSO and share state.
{ id: "acct_mso", keys: ["sk_test_legacy", "sk_test_mso", "sk_test_emr", "pk_test_mso"], corpus: true },
{ id: "acct_pc", keys: ["sk_test_pc"] },
{ id: "acct_pp", keys: ["sk_test_pp"], apiVersion: "2025-02-24.acacia" },
],
})
const url = new URL(server.url)
const stripe = new Stripe("sk_test_mso", {
apiVersion: "2024-06-20",
host: url.hostname,
port: Number(url.port),
protocol: "http",
})
const customer = await stripe.customers.create({ email: "qa@example.com" })
await stripe.paymentMethods.attach("pm_card_visa", { customer: customer.id }) // a new pm_ id
await server.close()Or over raw HTTP, the way any Stripe client talks to it:
import { createServer } from "@crvouga/mockingbird-service-stripe/server"
const server = await createServer()
const response = await fetch(`${server.url}/v1/customers`, {
method: "POST",
headers: {
authorization: "Bearer sk_test_mso",
"content-type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ email: "qa@example.com" }),
})
const customer = (await response.json()) as { id: string; email: string }
await server.close()Pointing the app at it
stripe-node accepts host, port and protocol; route every new Stripe(...) through one options
factory reading STRIPE_API_HOST / STRIPE_API_PORT / STRIPE_API_PROTOCOL (the catalog's G-S1),
and give raw fetch('https://api.stripe.comβ¦') call sites the same base URL. Keys must look like
test keys (sk_test_β¦, rk_test_β¦; pk_test_β¦ for the Stripe.js stand-in). Point the browser at
the mock's GET /v3 instead of https://js.stripe.com/v3 (G-S3); session.url already points at
the mock's hosted page.
Accounts and namespaces
State is partitioned by account, and the account is chosen by API key:
PUT /__admin/accounts {"accounts": [{id, keys, apiVersion?, webhookSecrets?, corpus?, displayName?}]}(alsocreateRuntime({accounts})/serve --accounts <json|file>). Every key listed on an account acts as it. Any other test key is an account of its own (accountOfKey(key)), so an MSO key reading a PC object gets Stripe's exact404 resource_missing(No such payment_intent: 'pi_β¦').apiVersionis the account's default version (requests withoutStripe-Version) and the version its webhook payloads render at (default2024-06-20, what every backend receiver of ours pins).webhookSecrets: {"<receiver url>": "whsec_β¦"}delivers every event of the account there.corpus: trueseeds the recorded catalog (below).
Namespaces isolate parallel workers; each namespace has its own copy of every account. Carriers:
the x-mockingbird-namespace header, the /ns/<namespace>/β¦ path prefix, or by API key:
PUT /__admin/credentials {"credentials": {"sk_test_worker1": "w1"}} (stripe-node cannot add
headers). Hosted-page URLs carry the /ns/<namespace> prefix so the browser lands in the same one.
API versions
Stripe-Version picks the shape. 2024-06-20 (and anything before 2024-09-30) returns
invoice.discount (with the coupon embedded), invoice.charge / payment_intent / subscription /
paid / subscription_details, subscription.current_period_* and subscription.discount, and
invoice lines with price objects; 2025-02-24.acacia adds total_pretax_credit_amounts;
2025-03-31.basil and later (the vendored latest) drop those and use parent, pricing,
discount.source and item-level periods. charge.refunds appears only with expand[]=refunds at
every one of these versions. GET /v1/invoices/upcoming answers at the older versions and returns
Stripe's "deprecated" 404 at basil and later. Expansion is generic (any path through ids the mock
holds, ancestors included); Stripe's own rules are enforced: a non-expandable first segment is
This property cannot be expanded (metadata). and more than four levels is
property_expansion_max_depth (verified against test mode at all three versions).
Webhooks
Every state change records an event in the account's log (GET /v1/events, filterable by
types[] and created) and publishes it through the shared webhook hub, signed
Stripe-Signature: t=<wall-clock unix>,v1=<hex HMAC-SHA256(secret, "t.body")> over the exact bytes β
stripe.webhooks.constructEvent verifies them.
- Endpoints:
PUT /__admin/webhook-endpoints [{account, url, secret, enabledEvents: ["*"|β¦]}](accountis an account id or any of its keys; omit it to receive every account),serve --webhook-url/--webhook-secret, accounts'webhookSecrets, and endpoints created throughPOST /v1/webhook_endpoints(signed with thewhsec_returned at creation). One event fans out to every matching endpoint, as on Stripe. Retries follow the hub's schedule. GET /__admin/webhooks,/webhooks/events,POST /__admin/webhooks/:id/replay,/webhooks/flush.- Delivery faults: presets
webhook_duplicate(same event id twice β our receiver's in-flight dedupe answers 500),webhook_reorder(the next two swapped),webhook_drop(never delivered, still inGET /v1/eventsfor the replay worker). - Metadata is copied verbatim, so the PC route's quarantine rule (
metadata.intent β {pc_order, kb_membership, shop_purchase, stripe_membership}orsource=supplement+billingInvoiceId) only fires for sessions that would trip it on Stripe.
Events emitted: customer.*, payment_method.attached|detached|updated,
payment_intent.created|succeeded|payment_failed|canceled|requires_action|amount_capturable_updated,
charge.succeeded|failed|captured|refunded|dispute.created, setup_intent.created|succeeded|setup_failed|canceled|requires_action,
checkout.session.completed|expired|async_payment_succeeded,
invoice.created|finalized|updated|paid|payment_succeeded|payment_failed|voided|deleted|upcoming,
invoiceitem.created, customer.subscription.created|updated|deleted (with
data.previous_attributes), subscription_schedule.*, refund.created|updated|failed,
product.*, price.* (with previous_attributes), coupon.*, promotion_code.*,
test_helpers.test_clock.*.
Lifecycles and the clock
POST /__admin/clock {"advance": "32d"} (or set) moves the mock clock and immediately runs every
clock-driven lifecycle, so their webhooks fire at once; the served mock also ticks every second.
- Renewals: past
current_period_enda subscription cycles β asubscription_cycleinvoice is finalized and charged off-session to the default payment method, theninvoice.paid+customer.subscription.updated(previous_attributes.current_period_end), orinvoice.payment_failedandpast_due. Trials end into a cycle;cancel_at_period_endcancels (customer.subscription.deleted).invoice.upcomingfires 3 days before renewal. incompletesubscriptions becomeincomplete_expiredafter 23 h (their invoice is voided).- Checkout Sessions expire at
expires_at(checkout.session.expired). - Schedule phases advance; the last one releases or cancels per
end_behavior. - Test clocks: customers created with
test_clocklive on the clock's time;POST /v1/test_helpers/test_clocks/:id/advanceruns their lifecycles and the clock readsreadyon the next retrieve. POST /__admin/tickruns the lifecycle without moving the clock.
Payment behaviour: payment_behavior omitted (allow_incomplete) charges the default payment method
now and returns incomplete on a decline; error_if_incomplete fails the call with the 402;
default_incomplete leaves the first invoice's PaymentIntent (with its client_secret) for the
customer, and paying it through Stripe.js activates the subscription. trial_end,
backdate_start_date + billing_cycle_anchor + proration_behavior=none (a $0 first invoice),
item updates with always_invoice (billed now) or create_prorations (next invoice), and
discounts with stable di_ ids (discounts="" clears) are modelled. A $0 invoice is paid on
finalize; a customer credit balance is applied at finalize; void works only on open invoices
(You can only pass in open invoices. This invoice isn't open.).
Hosted Checkout page and Stripe.js
GET /c/pay/:sessionIdβ the pagesession.urlpoints to: card number, expiry, CVC, ZIP and Pay/Cancel withdata-testidsstripe-mock-card,stripe-mock-exp,stripe-mock-cvc,stripe-mock-zip,stripe-mock-pay,stripe-mock-cancel(a decline showsstripe-mock-error). Pay completes the session (creating the customer, the PaymentIntent withpayment_intent_data.metadata, the Subscription withsubscription_data.metadata, or the SetupIntent), emitscheckout.session.completedand 302s tosuccess_urlwith{CHECKOUT_SESSION_ID}substituted raw and%7Bβ¦%7D-encoded; Cancel 302s tocancel_url.POST /__admin/checkout/sessions/:id/complete {"card": "4242β¦"}does the same without a browser;β¦/expireandβ¦/async_payment_succeededtoo.GET /v3β the Stripe.js stand-in:Stripe(pk),elements()βcreate("payment"|"card"),confirmPayment,confirmSetup,confirmCardPayment,confirmCardSetup,retrievePaymentIntent,retrieveSetupIntent,createPaymentMethod,handleCardAction. It callsPOST /v1/{payment,setup}_intents/:id/confirmwith the publishable key andclient_secret(the requests UI suites already wait for); 3-D Secure cards are authenticated in place.
A publishable key may only confirm or read an intent whose client_secret it presents, and create
payment methods; anything else is Stripe's 401.
Test values
- Payment methods:
pm_card_visa,pm_card_mastercard,pm_card_amex,pm_card_discover,pm_card_visa_debit,pm_card_chargeDeclined,pm_card_chargeDeclinedInsufficientFunds,pm_card_chargeDeclinedExpiredCard,pm_card_chargeCustomerFail,pm_card_authenticationRequired,pm_card_threeDSecure2Required,pm_card_createDisputeβ each use clones a newpm_. - Tokens:
tok_visa,tok_chargeCustomerFail(attaches, then every charge declines),tok_chargeDeclinedInsufficientFunds,tok_chargeDeclinedExpiredCard,tok_createDispute, β¦ - Card numbers (page, Stripe.js):
4242424242424242succeeds,4000000000000002declines,4000000000009995insufficient funds,40000025000031553-D Secure,4000051230000072the HSA card (funding: prepaid,issuer: OPTUM BANK, what our HSA/FSA detection matches). - Off-session declines answer 402
card_errorwithcharge,decline_code,advice_code,payment_methodand the failedpayment_intentembedded, as Stripe does. - Client secrets are
pi_<id>_secret_<x>/seti_<id>_secret_<x>.
Idempotency
POSTs with Idempotency-Key go through the shared IdempotencyStore, scoped per account: a
replay returns the stored response byte for byte (with idempotent-replayed: true); the same key
with different parameters is 400 idempotency_error; a concurrent request on an in-flight key is
409 idempotency_key_in_use, with Stripe's wording.
Fault presets
POST /__admin/faults {"preset": "<name>", "count"?: n}: card_declined, insufficient_funds,
expired_card, authentication_required (the next charge attempt declines), rate_limited (429
rate_limit), api_error (500 api_error), permission_error (403), connection_drop,
idempotency_in_flight (500 ms processing, so a concurrent retry gets 409), search_lag (search
hides objects younger than 60 s β search is consistent otherwise), webhook_duplicate,
webhook_reorder, webhook_drop.
Admin routes (beyond the standard contract)
GET|PUT /__admin/accounts, PUT /__admin/webhook-endpoints, PUT /__admin/refunds/:id {status, failure_reason} (emits refund.failed / refund.updated), POST /__admin/disputes {payment_intent|charge, reason?, amount?} (emits charge.dispute.created),
POST /__admin/checkout/sessions/:id/complete|expire|async_payment_succeeded,
POST /__admin/setup_intents/:id/succeed, GET /__admin/charges/:id, POST /__admin/tick. The
standard ones (/health, reset, snapshots, clock, faults, metrics, GET /__admin/requests,
credentials, webhooks) come from the shared runtime. The journal records operation, status and ids
only β never bodies, card numbers or emails.
Corpus
GEVITI_CORPUS is the recorded test-mode catalog our seeded fixtures point at (reference-data
products and prices, catalog plans, shop fixtures, QA snapshots such as prod_SNj3rQYHrHNS0H /
price_1StxtjGBBGmxLhdL8PzNSEgX, and runbook coupons and promotion codes; 144 products, 172
prices). Accounts with corpus: true answer those ids byte for byte; customers, intents and
subscriptions are never recorded. The membership lookup keys our env expects
(membership_<tier>_<interval>, e.g. membership_plus_annually) are attached to the matching
recorded prices (listed in synthesizedLookupKeys). Pass your own with createRuntime({corpus}).
API
StripeAPI is the engine; createRuntime wraps it in the service contract. From
@crvouga/mockingbird-service-stripe:
| Export | Description |
|---|---|
createRuntime |
(options?) => StripeRuntime β the mock with the full contract. Options: accounts, webhooks {endpoints, retryDelaysMs, fetch}, corpus, publicUrl, webhookApiVersion, lifecycle, tickMs, sqlite, clock, seed, adminKey, onLog, onWebhook. The runtime adds webhooks, accounts, tick(), stop(). |
StripeAPI |
Class; new StripeAPI(options?) implements fetch(request). Members: reset(), tick(force?), webhookEvents(account?), webhookDeliveryAttempts(account?), apiWebhookEndpoints(), accountIds(), scopeFor(account), importStateFrom(source), accounts, app, sqlite. |
STRIPE_PRESETS |
The named fault presets above. |
AccountDirectory |
Keys β accounts (configure, accountFor, config, list, resolve). |
DEFAULT_WEBHOOK_API_VERSION |
"2024-06-20". |
accountOfKey |
(key) => string β the account id of an unconfigured key. |
accountOf |
(request) => string β the same, from a request's bearer key. |
STRIPE_API_VERSION |
The vendored latest version (2026-08-26.dahlia). |
LEGACY_API_VERSION |
"2024-06-20". |
ACACIA_API_VERSION |
"2025-02-24.acacia". |
GEVITI_CORPUS |
The bundled recorded catalog. |
TEST_TOKENS |
Every modelled tok_β¦. |
TEST_PAYMENT_METHOD_IDS |
Every modelled magic pm_card_β¦. |
TEST_CARD_NUMBERS |
Every modelled test card number. |
STRIPE_NAMESPACE |
"stripe" β SQLite namespace of every record. |
document |
The vendored OpenAPI document (Mockingbird subset). |
operationIds |
Every operationId in document. |
supportedOperationIds |
The ones the mock implements. |
QA_SURFACE_OPS |
Operations the parity walks cover (supported, minus the browser pages). |
QA_METADATA |
Pinned metadata values the parity walks send. |
QA_AMOUNTS |
Pinned amounts in cents. |
QA_CUSTOMER |
Pinned customer email, name, phone. |
QA_TEST_PAYMENT_METHODS |
Test payment methods the walks use. |
QA_TEST_CARD_TOKENS |
Test card tokens the walks use. |
QA_SEARCH_QUERIES |
Search queries the walks issue. |
QA_COUPON_CODES |
Promotion codes the walks use. |
reshapeQaCommand |
Parity-walk hook pinning sampled commands onto those values. |
From @crvouga/mockingbird-service-stripe/server (Node): createServer(options?) (runtime options
plus port, host; resolves {url, port, runtime, close}), serveTarget (the serve wiring:
--accounts, --webhook-url, --webhook-secret, --public-url) and DEFAULT_PORT (12111).
Deliberately not modelled
- Stripe.js internals: the stand-in covers the calls our UI makes; Payment Element wallets,
Link,
paymentRequest(it reports no wallet) and Elements styling are not modelled. Native PaymentSheet cannot be redirected (member-app native keeps its fake provider). - Connect (
Stripe-Account, application fees, transfers), tax, shipping, Radar, mandates, meters, quotes, credit notes, payouts, and non-card payment methods (bank debits, wallets). - Smart retries / dunning: a failed renewal goes
past_dueonce; later automatic retries,unpaidand dunning emails are not run. - Proration arithmetic is day-fraction approximate (Stripe prorates to the second);
auto_advancedrafts are not finalized an hour later. - Webhook endpoint
api_version: payloads render at the account's version, not per endpoint. - Live keys (
sk_live_β¦) are refused with Stripe's 401: the mock is test mode only. - Operations marked unsupported in SUPPORT.md (charge create/update, checkout session update, dispute evidence).
- Rate-limit, 5xx and permission bodies come from presets, worded as Stripe words them but not recorded from traffic.
Development
For contributors to the mockingbird repo only; these scripts are not shipped in the npm package.
bun test # self-parity, acceptance (via test/consumer.ts), stripe-node drop-in, contract
bun run parity # live differential parity against Stripe test mode (safe operations)
bun run parity -- --include-unsafe --only PostCustomers,GetCustomers
bun run client-parity # stripe-node smoke proof with webhook signature verification
bun run vendor # re-vendor openapi.yaml from the pinned upstream specLive parity loads MOCKINGBIRD_STRIPE_SECRET_KEY (a sk_test_ key) from the environment or Vault
(secret/personal/prd) and exits 2 without one. By default it walks only safe operations and
leaves out account-global ones (account profile, lifetime balance, lingering test clocks and
webhook endpoints).
Part of mockingbird β agent integration guide: README Β· llms.txt Β· report an issue or request a feature.