Local emulators. Real API contracts. Part of Mockingbird.
Stateful emulator of the Flex (withflex.com) HSA/FSA payments API for test suites: products
(answered from a recorded catalog corpus), checkout sessions in payment (one-time),
subscription, off_session and setup modes, subscriptions, customers, setup intents,
refunds, the hosted checkout page, and the Svix-signed webhooks Flex posts back. A UI checkout that drove the real
checkout.withflex.com page and then waited on a 5-minute reconciler settles here in
milliseconds: the page is local, and the signed webhook reaches the app as soon as the card is
accepted.
- Operation coverage: SUPPORT.md
- Flex publishes no machine-readable spec: the contract (
openapi.yaml) is hand-authored from the wire shapes our consumer reads and writes (B/billing/flex/), and every field its zod schemas require is served. Subscription mode,price_data.recurringand the subscription object follow the Flex API reference (there is no sandbox recording).
Install
npm install -D @crvouga/mockingbird-service-flexESM only. Node >= 22 or Bun >= 1.2. No native dependencies. Serve it with
npx mockingbird-flex serve, createServer from ./server (Node), or createRuntime with any
Fetch server.
Usage
Point the app at the emulator:
| Env | Value |
|---|---|
FLEX_API_BASE_URL |
http://127.0.0.1:8792 (or …/__admin/ns/<namespace>) |
FLEX_API_KEY |
any fsk_test_… key (test mode); fsk_… is live mode; other formats get 401 |
FLEX_WEBHOOK_SECRET |
the same value as --webhook-secret: fwhsec_<base64> or whsec_<base64> |
npx mockingbird-flex serve --port 8792 \
--webhook-url http://127.0.0.1:3000/billing/webhooks/flex \
--webhook-secret "$FLEX_WEBHOOK_SECRET"import { createServer } from "@crvouga/mockingbird-service-flex/server"
// Test setup: the emulator, posting signed webhooks to the app's handler.
const server = await createServer({
webhooks: {
url: "http://127.0.0.1:3000/billing/webhooks/flex",
secret: "fwhsec_ZmxleC1tb2NrLXNpZ25pbmcta2V5",
},
})
// Application code: FLEX_API_BASE_URL is server.url, FLEX_API_KEY any fsk_test_ key.
const baseUrl = server.url
const created = await fetch(`${baseUrl}/v1/checkout/sessions`, {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer fsk_test_suite" },
body: JSON.stringify({
checkout_session: {
success_url: "https://app.test/done?session_id={CHECKOUT_SESSION_ID}",
cancel_url: "https://app.test/cart",
client_reference_id: "attempt-1",
line_items: [
{ price_data: { product: "fprod_01m0tgysj4ahvf8fas60c2ef2d", unit_amount: 4500 }, quantity: 1 },
],
},
}),
})
const { checkout_session } = (await created.json()) as {
checkout_session: { checkout_session_id: string; url: string }
}
// Test control: open checkout_session.url in the browser and pay with 4000 0512 3000 0072, or:
await fetch(`${baseUrl}/__admin/sessions/${checkout_session.checkout_session_id}/complete`, {
method: "POST",
})
await server.close()Browser E2E suites
Run npx mockingbird-flex serve on a port, point FLEX_API_BASE_URL at it, and the browser
lands on the emulator's page instead of checkout.withflex.com: no network, no shared Flex
account, and the signed webhook reaches the app as soon as Pay is clicked. The page URL takes
the emulator's origin, or --public-url when given. A suite that finds the Flex tab by host
(/\bcheckout\.withflex\.com\b/) keeps working with
--public-url http://checkout.withflex.com.localhost:8792: Chromium resolves every
*.localhost name to loopback. Other browsers may need a hosts entry.
Routes
All API bodies are wrapped: {product: {…}}, {checkout_session: {…}}, {customer: {…}},
{setup_intent: {…}}, {products: […], has_more}, {checkout_sessions: […], has_more}.
Errors are {error: {type, message, param?}}.
| Route | Behaviour |
|---|---|
GET /v1/products?limit=&starting_after= |
Oldest first; limit 1–100 (default 10). The corpus comes first, then created products. |
POST /v1/products |
{product: {name, description?, url?, client_reference_id?, metadata?}}. New products are active, hsa_fsa_eligibility: null until classified (PUT /__admin/products/:id). |
GET /v1/products/{id} |
product_id, name, description, url, client_reference_id, hsa_fsa_eligibility, visit_type, active, test_mode, metadata, created_at. |
PATCH /v1/products/{id} |
{product: {active?, name?, description?, url?, metadata?}}; emits product.updated. |
POST /v1/checkout/sessions |
Idempotency-Key honoured. mode payment (≥1 line item), subscription (≥1 line item whose price_data.recurring is {interval: day|week|month|year, interval_count?}, else 400; optional subscription_data: {cancel_at_period_end?, metadata?}), setup (a customer and no line items, else 400), off_session (customer + a saved payment_method, charged before answering: the response is already complete, or its expanded payment intent is requires_payment_method / requires_action per offSessionOutcome). Unknown or inactive products, customers and payment methods are 400. redirect_url and url are the hosted page. |
GET /v1/checkout/sessions/{id}?expand_customer=true&expand_payment_intent=true |
The session; expansions return customer / payment_intent objects instead of ids. |
GET /v1/checkout/sessions?client_reference_id=&limit=&starting_after= |
Newest first (our ambiguous-create recovery). |
POST /v1/checkout/sessions/{id}/refund |
Idempotency-Key honoured. {checkout_session: {}} (full) or {checkout_session: {amount}}; 400 when unpaid or over-refunded. |
POST /v1/customers |
Idempotency-Key honoured. {customer: {first_name, last_name, email, phone}} (all required). |
GET /v1/setup_intents/{id}?expand=customer,payment_method |
setup_intent_id, status, customer, payment_method. |
GET /v1/subscriptions/{id} |
{subscription: {subscription_id, status, items, customer, default_payment_method, cancel_at_period_end, current_period_start, current_period_end, canceled_at, metadata, test_mode, created_at}}. A paid subscription-mode session starts one: active, items = its recurring line items, the period one interval of the first recurring item (month/year steps clamp to the month's last day), charged to the card just used; the session's subscription holds its id. |
Auth: Authorization: Bearer fsk_test_… or fsk_…; a missing key, or any other format
(sk_test_…, whsec_…), is 401 authentication_error. test_mode on created objects follows
the key. Same key + same body replays the stored response; same key + a different body is 400
idempotency_error; a concurrent request with an in-flight key is 409.
Hosted page
GET /pay/{sessionId} renders a plain form (no scripts) with data-testids
flex-mock-email, -first-name, -last-name, -phone, -card, -exp, -cvc, -zip,
-pay, -cancel, -error, -amount, and on the letter step -lmn-submit. Inputs also carry
the names and placeholders our codecept locators look for (cardNumber, expiry, cvc,
postalCode, email, …). POST /pay/{sessionId} submits it:
| Card | Result |
|---|---|
4000 0512 3000 0072 |
HSA card: succeeds. |
4242 4242 4242 4242 (or any other valid card) |
Succeeds; if a line item's product is letter_of_medical_necessity, the session gets next_action: {type: "collect_letter_of_medical_necessity", collect_letter_of_medical_necessity: {url}} and the browser goes to that step; submitting it completes the payment. |
4000 0000 0000 0002 |
Declines: 402 page with <div role="alert">Your card was declined.</div>; the payment intent is requires_payment_method. |
Success 302s to success_url with {CHECKOUT_SESSION_ID} substituted (raw and
%7BCHECKOUT_SESSION_ID%7D); GET /pay/{id}/cancel (the Cancel link) 302s to cancel_url,
leaving the session open. A contact email on a session without a customer creates one. In a
namespace the page URL carries /__admin/ns/<name> (the browser sends no headers); publicUrl
overrides the origin.
Webhooks
Svix-signed (svix-id, svix-timestamp = wall clock, svix-signature: v1,<base64 HMAC-SHA256(key, "<id>.<ts>.<body>")>, key = base64-decoded secret after fwhsec_/whsec_).
Body: {event: {event_id, event_type, object, event_dt, test_mode, created_at}}. As in Flex's
OpenAPI (EventObject) and production deliveries, object holds one key named for the
resource: checkout events {checkout_session} (so object.checkout_session.checkout_session_id),
payment-intent events {payment_intent}, charge.* events {charge} (its intent is
charge.payment_intent), refund.* and charge.refund.updated events {refund} (its intent is
refund.payment_intent_id), customer.subscription.* {subscription} and product.updated
{product}. Payment-intent, charge and refund objects name no checkout session: a receiver
finds the payment through the payment intent.
| Event | When |
|---|---|
payment_intent.succeeded, then checkout.session.completed |
the page (or …/complete, or an off-session charge) settles a session |
customer.subscription.created (the subscription), before those two |
a subscription-mode session settles |
checkout.session.async_payment_succeeded |
settling a session whose intent was processing |
checkout.session.async_payment_failed |
a decline (page, admin, off-session) |
checkout.session.expired |
…/expire, or expires_at passing on the emulator clock (default 24 h) |
checkout.session.refunded (the session, with every refund in refunds), then refund.created (twice), charge.refunded, refund.updated, charge.refund.updated |
each refund, through the API or the dashboard (POST /__admin/sessions/:id/refund). The refund and charge events follow the order production delivers a dashboard refund; each refund's items name its payment intent |
product.updated |
PATCH /v1/products/{id} and PUT /__admin/products/:id |
checkout_session.completed, checkout_session.expired |
the aliases, with PUT /__admin/settings {"eventNaming": "underscored"} |
An endpoint receives only the event types it subscribes to, as in Flex's dashboard: pass
webhooks: {events: [...]} (default: every type) or PUT /__admin/webhook-endpoints. Subscribe
to what your production endpoint subscribes to, so a test cannot pass on an event production
never receives.
POST /__admin/events {type, session | product} emits any type on demand. Non-2xx answers are
retried (immediately, 5 s, 5 min, 30 min, 2 h); GET /__admin/webhooks, …/events,
POST /__admin/webhooks/flush, …/:id/replay, PUT /__admin/webhook-endpoints as usual.
Admin (beyond the standard contract)
| Route | Effect |
|---|---|
PUT /__admin/products/:id |
{hsa_fsa_eligibility?, active?, test_mode?, visit_type?, client_reference_id?, name?, metadata?}; emits product.updated. |
POST /__admin/sessions/:id/complete |
{card?}: settle as if paid (HSA card unless card is 4242…). |
POST /__admin/sessions/:id/decline |
Payment intent → requires_payment_method. |
POST /__admin/sessions/:id/expire |
Session → expired. |
POST /__admin/sessions/:id/refund |
{amount?}: a refund made in Flex's dashboard (full by default); emits the refund events. 409 when unpaid, 400 over the unrefunded amount. |
POST /__admin/sessions/:id/require_action |
{next_action_type?}: collect_letter_of_medical_necessity (default), provide_second_payment_method, provide_alternative_payment_method, payment_failed. |
PUT /__admin/sessions/:id/payment-intent |
{status, amount_received?}: requires_payment_method, requires_action, processing, succeeded, canceled. |
GET /__admin/sessions, GET /__admin/sessions/:id |
The namespace's sessions. |
POST /__admin/events |
Emit any event type for a session or product. |
GET/PUT /__admin/settings |
{eventNaming, offSessionOutcome, sessionTtlSeconds, lmnOnRegularCard, publicUrl}. |
POST /__admin/tick |
Expire due sessions now (the served emulator ticks every 100 ms). |
Every orchestrator state is reachable: pending (open), action_required (require_action, or an
intent requires_action), processing, canceled (intent canceled, or expired), failed
(decline), succeeded, refunded (full refund), quarantined (partial refund,
amount_mismatch, duplicate sessions).
Fault presets (POST /__admin/faults {"preset": "<name>", "count"?: n}; GET /__admin/faults/presets):
create_4xx (400, nothing created), create_5xx (creates, then 500: recovery adopts it),
create_5xx_not_created, timeout (creates, answers after 16 s, past the client's 15 s abort;
params.delayMs overrides), invalid_shape (no redirect_url/url), amount_mismatch
(amount_total + 100), duplicate_sessions_for_client_reference (two sessions, then 500),
refund_4xx, server_error, webhook_duplicate, webhook_reorder, webhook_drop.
Namespaces
x-mockingbird-namespace, a /__admin/ns/<name> prefix on FLEX_API_BASE_URL, or by API key:
PUT /__admin/credentials {"credentials": {"<FLEX_API_KEY>": "<namespace>"}}.
Corpus
src/corpus/products.ts is the product side of every row of the consumer's
flexCatalogMappings reference fixture (663 rows, regenerated with
bun scripts/corpus.ts <fixture.json>): product id, client reference, the acme_purpose /
acme_merchant_product_id / acme_client_reference_id metadata our catalog validation
compares, eligibility and visit type. Every product is active and test-mode, so our validation
reproduces each mapping row's own active flag. No sandbox recording exists (no credentials),
so product names are synthesised.
Deliberately not modelled
- Real card processing, Stripe iframes and split-tender payments: the page is a plain form, and
split payment is reachable only as
next_action: provide_second_payment_method. - The letter-of-medical-necessity questionnaire: one submit button stands in for it.
- Test/live data separation: a live key sees the same objects (only
test_modediffers). - Subscription lifecycle after checkout: renewals, invoices and the
invoice.*events, trials,customer.subscription.updated/.deleted, and the subscription update/cancel routes. A subscription staysactivefor its first period. The Prices API (priceids in line items) is not modelled either: use inlineprice_data. - Coupons, promotion codes (
allow_promotion_codesis echoed only), partial captures, disputes. - Flex's exact error texts and ids: shapes follow what our consumer reads; ids look like
fprod_01z…,fcs_01z…,fcus_…,fpi_…,fseti_…,fpm_…,fevt_….
API
| Export | Kind | Description |
|---|---|---|
FlexAPI |
class | The in-process emulator: fetch(request), reset(), settle(id), decline(id), expire(id), requireAction(id, type), setPaymentIntent(id, patch), applyRefund(id, amount), dashboardRefund(id, amount?), putProduct(product), emitFor(type, target), tick(), sessions(), subscriptions(), present(session, view). Options: sqlite, now, namespace, publicNamespace, products, settings, onEvent. |
createRuntime |
function | The emulator with the full service contract. Options: webhooks: {url, secret, events?, retryDelaysMs?, fetch?}, products, settings, tickMs, clock, seed, adminKey, onLog. |
FLEX_PRESETS |
object | Every named fault preset. |
FLEX_NAMESPACE |
string | The service name, "flex". |
FLEX_EVENT_TYPES |
array | Every webhook event type, aliases included. |
eventObjectKey |
function | The key an event type's object is nested under (refund.created → refund, charge.refund.updated → refund). |
keyMode |
function | "test" for fsk_test_…, "live" for fsk_…, otherwise undefined. |
isNextActionType |
function | Whether a string is a next-action type. |
CARDS, classifyCard, substituteSessionId |
values | The hosted page's test cards, its card classifier, and the {CHECKOUT_SESSION_ID} substitution. |
CORPUS_ROWS, corpusProduct |
values | The recorded product corpus and its row → product mapping. |
DEFAULT_SETTINGS, ELIGIBILITIES, NEXT_ACTION_TYPES, PAYMENT_INTENT_STATUSES, SUBSCRIPTION_STATUSES |
values | Defaults and enums. |
periodEnd |
function | periodEnd(startMs, {interval, interval_count?}): the end of a billing period (epoch ms). |
document, operationIds, supportedOperationIds |
values | The vendored OpenAPI contract and its operation ids. |
createServer, serveTarget, DEFAULT_PORT (./server) |
Node | Serve over node:http (expiry ticks every 100 ms); the serve CLI target (--webhook-url, --webhook-secret, --public-url, --event-naming); port 8792. |
Part of mockingbird.