@crvouga/mockingbird-service-flex

中文文档
4.1.0发布于 2026/11/11MITTypeScriptES Module构建来源已验证近 30 天下载 1.7万

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

原始使用文档
文档目录11 节
  1. Install
  2. Usage
  3. Browser E2E suites
  4. Routes
  5. Hosted page
  6. Webhooks
  7. Admin (beyond the standard contract)
  8. Namespaces
  9. Corpus
  10. Deliberately not modelled
  11. API

@crvouga/mockingbird-service-flex

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.recurring and the subscription object follow the Flex API reference (there is no sandbox recording).

Install

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 emulator:

EnvValue
FLEX_API_BASE_URLhttp://127.0.0.1:8792 (or …/__admin/ns/<namespace>)
FLEX_API_KEYany fsk_test_… key (test mode); fsk_… is live mode; other formats get 401
FLEX_WEBHOOK_SECRETthe 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?}}.

RouteBehaviour
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/sessionsIdempotency-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=trueThe 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}/refundIdempotency-Key honoured. {checkout_session: {}} (full) or {checkout_session: {amount}}; 400 when unpaid or over-refunded.
POST /v1/customersIdempotency-Key honoured. {customer: {first_name, last_name, email, phone}} (all required).
GET /v1/setup_intents/{id}?expand=customer,payment_methodsetup_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:

CardResult
4000 0512 3000 0072HSA 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 0002Declines: 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.

EventWhen
payment_intent.succeeded, then checkout.session.completedthe page (or …/complete, or an off-session charge) settles a session
customer.subscription.created (the subscription), before those twoa subscription-mode session settles
checkout.session.async_payment_succeededsettling a session whose intent was processing
checkout.session.async_payment_faileda 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.updatedeach 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.updatedPATCH /v1/products/{id} and PUT /__admin/products/:id
checkout_session.completed, checkout_session.expiredthe 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)

RouteEffect
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/declinePayment intent → requires_payment_method.
POST /__admin/sessions/:id/expireSession → 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/:idThe namespace's sessions.
POST /__admin/eventsEmit any event type for a session or product.
GET/PUT /__admin/settings{eventNaming, offSessionOutcome, sessionTtlSeconds, lmnOnRegularCard, publicUrl}.
POST /__admin/tickExpire 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_mode differs).
  • Subscription lifecycle after checkout: renewals, invoices and the invoice.* events, trials, customer.subscription.updated / .deleted, and the subscription update/cancel routes. A subscription stays active for its first period. The Prices API (price ids in line items) is not modelled either: use inline price_data.
  • 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

ExportKindDescription
FlexAPIclassThe 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.
createRuntimefunctionThe emulator with the full service contract. Options: webhooks: {url, secret, events?, retryDelaysMs?, fetch?}, products, settings, tickMs, clock, seed, adminKey, onLog.
FLEX_PRESETSobjectEvery named fault preset.
FLEX_NAMESPACEstringThe service name, "flex".
FLEX_EVENT_TYPESarrayEvery webhook event type, aliases included.
eventObjectKeyfunctionThe key an event type's object is nested under (refund.created → refund, charge.refund.updated → refund).
keyModefunction"test" for fsk_test_…, "live" for fsk_…, otherwise undefined.
isNextActionTypefunctionWhether a string is a next-action type.
CARDS, classifyCard, substituteSessionIdvaluesThe hosted page's test cards, its card classifier, and the {CHECKOUT_SESSION_ID} substitution.
CORPUS_ROWS, corpusProductvaluesThe recorded product corpus and its row → product mapping.
DEFAULT_SETTINGS, ELIGIBILITIES, NEXT_ACTION_TYPES, PAYMENT_INTENT_STATUSES, SUBSCRIPTION_STATUSESvaluesDefaults and enums.
periodEndfunctionperiodEnd(startMs, {interval, interval_count?}): the end of a billing period (epoch ms).
document, operationIds, supportedOperationIdsvaluesThe vendored OpenAPI contract and its operation ids.
createServer, serveTarget, DEFAULT_PORT (./server)NodeServe 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.

版本与发布记录

@crvouga/mockingbird-service-flex 共发布 45 个版本,首次发布于 2026/09/23;过去 12 个月发布了 45 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
4.1.0latest2026/11/11今天
4.0.92026/11/038 天前
4.0.82026/11/038 天前
4.0.72026/11/038 天前
4.0.62026/11/038 天前
4.0.52026/11/038 天前

关于 @crvouga/mockingbird-service-flex 的常见问题

@crvouga/mockingbird-service-flex 是什么?

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

如何安装 @crvouga/mockingbird-service-flex?

在项目目录运行 npm install @crvouga/mockingbird-service-flex,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 mockingbird-flex,可以用 npx mockingbird-flex 直接运行。

@crvouga/mockingbird-service-flex 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@crvouga/mockingbird-service-flex 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@crvouga/mockingbird-service-flex 可以商用吗?

@crvouga/mockingbird-service-flex 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@crvouga/mockingbird-service-flex 的下载量有多少?

根据 npm 官方下载统计,@crvouga/mockingbird-service-flex 在 2026/10/11 – 2026/11/09 这 30 天内被下载 16,928 次,日均约 564 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@crvouga/mockingbird-service-flex 还在维护吗?

最新版本 4.1.0 发布于 2026/11/11(今天);至今共发布 45 个版本,其中过去 12 个月发布了 45 个;首次发布于 2026/09/23。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。