Flex

@crvouga/mockingbird-service-flex

Stateful mock of the Flex HSA/FSA payments API: products (recorded catalog corpus), checkout sessions in payment, off-session and setup modes, customers, setup intents, refunds, the hosted checkout page, and Svix-signed webhooks.

Work in progress. Usable, but incomplete: operations, response shapes and options can still change between releases. Pin an exact version. Check the README's "Deliberately not modelled" notes before relying on it.
Work in progressPayments Runs in the browser13 of 13 operationsIn-process fetchNode servermockingbird-flex
$npm install -D @crvouga/mockingbird-service-flex

Playground

Requests go to the real mock running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-flex into this tab on first send
https://api.withflex.com

A secret key, `fsk_test_…` (test mode) or `fsk_…` (live mode).

Send a request to see the mock's response.

Operations

13 of 13 operations in the vendored contract are mocked. Hand-authored from the wire shapes our consumer reads and writes (flex-api.client.ts, flex-payment.types.ts, flex-product.types.ts, flex-webhook.service.ts, do-flex-hosted-checkout.ts).

MethodPathOperationStatus
GET/v1/productsListProducts
POST/v1/productsCreateProduct
GET/v1/products/{productId}GetProduct
PATCH/v1/products/{productId}UpdateProduct
GET/v1/checkout/sessionsListCheckoutSessions
POST/v1/checkout/sessionsCreateCheckoutSession
GET/v1/checkout/sessions/{sessionId}GetCheckoutSession
POST/v1/checkout/sessions/{sessionId}/refundRefundCheckoutSession
POST/v1/customersCreateCustomer
GET/v1/setup_intents/{setupIntentId}GetSetupIntent
GET/pay/{sessionId}HostedCheckoutPage
POST/pay/{sessionId}SubmitHostedCheckout
GET/pay/{sessionId}/cancelCancelHostedCheckout

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

Stateful mock of the Flex (withflex.com) HSA/FSA payments API for test suites: products (answered from a recorded catalog corpus), checkout sessions in payment, off_session and setup modes, 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.

Install

Shell
npm install -D @crvouga/mockingbird-service-flex

ESM 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 mock:

Env Value
FLEX_API_BASE_URL http://127.0.0.1:8792 (or …/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>
Shell
npx mockingbird-flex serve --port 8792 \
  --webhook-url http://127.0.0.1:3000/billing/webhooks/flex \
  --webhook-secret "$FLEX_WEBHOOK_SECRET"
TypeScript
import { createRuntime } from "@crvouga/mockingbird-service-flex"

const flex = createRuntime({
  webhooks: {
    url: "http://127.0.0.1:3000/billing/webhooks/flex",
    secret: "fwhsec_ZmxleC1tb2NrLXNpZ25pbmcta2V5",
  },
})
const post = (path: string, body: unknown) =>
  flex.fetch(
    new Request(`http://flex.test${path}`, {
      method: "POST",
      headers: { "content-type": "application/json", authorization: "Bearer fsk_test_suite" },
      body: JSON.stringify(body),
    }),
  )

const { checkout_session } = (await (
  await post("/v1/checkout/sessions", {
    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 },
      ],
    },
  })
).json()) as { checkout_session: { checkout_session_id: string; url: string } }
// …open checkout_session.url in the browser and pay with 4000 0512 3000 0072, or:
await post(`/__admin/sessions/${checkout_session.checkout_session_id}/complete`, {})

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), 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.

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 /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}}. Checkout events carry the session (so object.checkout_session_id), payment-intent events the intent plus checkout_session_id, refund events checkout_session and payment_intent, and product.updated the product (object.product_id).

Event When
payment_intent.succeeded, then checkout.session.completed the page (or …/complete, or an off-session charge) settles a session
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 mock clock (default 24 h)
refund.created, charge.refunded, checkout.session.refunded, refund.updated, charge.refund.updated each refund
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"}

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/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 mock 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 /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 geviti_purpose / geviti_merchant_product_id / geviti_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_mode differs).
  • Subscriptions, coupons, promotion codes (allow_promotion_codes is 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 mock: fetch(request), reset(), settle(id), decline(id), expire(id), requireAction(id, type), setPaymentIntent(id, patch), applyRefund(id, amount), putProduct(product), emitFor(type, target), tick(), sessions(), present(session, view). Options: sqlite, now, namespace, publicNamespace, products, settings, onEvent.
createRuntime function The mock with the full service contract. Options: webhooks: {url, secret, 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.
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 values Defaults and enums.
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.

to navigate to open