Test mode
Test mode is on in production since dashboard v2 (2026-10-08), for a narrow set of
operations. Two operator switches gate it, and both are on: api_v1.test_mode (every sk_test_
call) and api_v1.test_mode_forwarding (the calls that run in the development project). A
sk_test_ key reaches only the operations the coverage table below marks full or sandbox,
within what a business key may call: API-key, request-log and account reads on the test twin
account, and webhook endpoints, webhook deliveries and the per-object event lists in the
development project. Every other operation a business key may call answers it
503 temporarily_unavailable — payment links, subscriptions, payouts, payroll, the wallet, quotes,
orders, customers and capabilities included. If either switch is turned off, the operations it
gates answer 503 temporarily_unavailable again. Four operations enforce the refusal a second
time, in their own handler code, independent of the platform-wide gate — quotes.create,
quotes.get, orders.create and customers.create — each answering 503 temporarily_unavailable
before contacting any provider, releasing the idempotency reservation since nothing was
dispatched. Every /v1 projection echoes the reading key's mode:
no route projection hardcodes livemode: true (the last two, customer
and payment, were fixed in A4F-DOCS), and test_mode.test.ts fails if one ever does again: that
source scan over api-v1/routes is the G7 guard. A separate walk in test_mode_inbound.test.ts
proves only that the DEV mirror principal answers livemode: false; it runs a stub handler, not
the real forwarded routes, and a projection test over those handlers is still open. The order and customer event payloads take the account's
mode when they are emitted.
How the mode is selected
The fork is never the key prefix — it is caller.livemode, an accounts.livemode column
resolved once at auth and never re-read from a header or a body field. A live caller is completely
untouched by anything on this page; the routing below exists only on the livemode=false path.
sk_live_…→ the caller's live account, production, live providers — no change, ever.sk_test_…→ the caller's test twin account (acct_test…, linked from the live account byaccounts.test_twin_account_id, created implicitly the first timePOST /v1/api_keysis called withlivemode: false) — then one of three things below, decided per operation.
What happens on a test-mode call
Every gate that already runs for a live caller — the account's own rate limit, kill switches,
scopes, idempotency reservation — runs identically first, for every caller. The fork is the very
last step, and it resolves to exactly one of three dispositions, set on every route in
api-v1/router.ts's table (AuthenticatedRoute.testMode, required, no default — a route with
none of the three values fails to compile):
local— the same handler runs in production, scoped to the test twin account. No provider is in the loop, so there is nothing to forward. Exactly nine operations run this way: the developer operationsapi_keys.*andrequest_logs.list(credentials and the outer usage log are prod-only by design), plusaccount.getandaccounts.list, which read the test twin account itself. A signed-in session in test mode lists only its test twins, never its live accounts. Theirmarketcomes only from the twin's declaredcountryor the default, never from the owner's verification data. Ansk_test_key resolves the twin's owner, who is the live account holder, and reads exactly one user-level field of theirs:email, the owner's login email, the same in both modes. Treat ansk_test_key as able to read that email. The other user-level fields are withheld from a test key and replaced by defaults, so the shape matches live:languageandsupport_contactarenull,notification_preferencesis{marketing: false, product_tips: true}anddashboard_preferencesis{}. They are never the owner's values.callerdescribes the credential, not the owner, so it is published in both modes:{credential: business_key, role: null}for a key. A signed-in session in test mode is the owner themself and sees its own values.account.update,accounts.createand the two waitlists arerefused: they write live account state.account.updatealso refuses, in its handler, any user-level field (language, preferences, support contact) from a test caller with400 livemode_boundarybefore any write.accounts.createalso refuses a test caller in its handler (503,sideEffectFree), because a new account is always live. A test caller therefore never reaches the one-account-per-login409 account_already_existsa live caller gets.forward— the request runs in the sandbox environment over a signed, single-use hop. The hop carries only the request itself and an opaque account id: never the caller's key, never a service-role credential, never the caller's IP address or user agent. The signature covers the method, path, query string andIdempotency-Key, and a request that differs from the signed values is refused. In the sandbox the handler runs against the Bridge sandbox where it calls Bridge. The contract also defines afixturesvalue for a provider with no sandbox, but no fixture exists in code today: neitherfixturesoperation is routed, so nothing answers with one. Production passes through only an answer the sandbox executor produced. Anything else becomes503 temporarily_unavailablewithRetry-After: 60; it is markedsideEffectFreeonly for a read, and no platform body is ever shown.refused—503 temporarily_unavailable,sideEffectFree: true, before any handler runs. The four operations named above earn this the honest way, in their own code;credit_checkouts. createearns it by design (below); every other operation whose contract value isunavailableisrefusedby its route too. So every operation the contract marksunavailableanswers503 temporarily_unavailableeven withapi_v1.test_modeon. Router and contract are tied by the strict parity test insupabase/functions/api-v1/__tests__/test_mode.test.ts: a route whose disposition differs from its contract value fails CI, with no exception list.
These guards stand between a test caller and live money. Each has its own test, none needs the network, and they are layered rather than independent: most act only once an earlier one has let the request through.
- G1, the fork. A live caller is never forwarded and a test caller never reaches a live
handler; swept over every route in the router's table (
test_mode.test.ts). - Prod-side second guard (A4F-M2). On production a test caller can run only the routes in
LOCAL_ALLOWLIST; every other handler refuses it before any money or owner lookup (test_mode.test.ts, "A4F-M2"). - G2, the destination. The DEV project ref is a literal; a hop that would loop back to itself
is refused (
test_mode.test.ts). - Sandbox posture is asserted before any provider call. The sandbox refuses to execute a hop unless its process-wide sandbox switch is on, every provider switch reads sandbox and no live-network setting is present; production refuses a request that already carries hop credentials; a live-mode assertion is refused in the sandbox. A live provider host is never a valid target for a test-mode request; the internal evidence for each of these guards lives with the engineering notes, not on this page.
- Projections never claim live mode for a test caller (see the top of this page).
Test mode settles Tempo only on the tempo-moderato testnet: activating a payment link whose
settlement destination is on tempo-mainnet is refused 422 settlement_rail_unsupported. Any
operation whose response carries funding or deposit instructions is unavailable in test mode, and
test_mode.test.ts fails if one is restored. Payout funding instructions already carry
sandbox: true next to their chain for a test caller, so a sandbox address is labelled as one.
Hosted pages
The payer page a payment link's url points to depends on the project that holds the link.
A test-mode link lives in DEV, so on DEV its url is https://staging.swaps.app/pay/<token>
(the dashboard build on the DEV backend), for a test key and a live key alike. Production keeps
https://swaps.app/pay/<token> for a live key; a test key on production has no page, so url
is null. The host is per-environment configuration (see Environments), never a literal in the code: while a project has no host
configured, url is null rather than a guessed address, and DEV never publishes a
swaps.app production URL where the token would 404. paymentLinkPublicUrl
(supabase/functions/api-v1/routes/payment_links_shared.ts, tests in
routes_payment_links_shared.test.ts and routes_payment_links.test.ts) decides this.
payment_links.create is unavailable in test mode
today, so the router refuses it before this code runs.
No outbound email
Test mode sends no email to anyone. The shared sender refuses every send made inside a test-mode
dispatch and every .invalid recipient (each test twin's synthetic principal is
test-acct_…@sandbox.invalid); the invoice email, the payment-link reminder and payer-nudge sweeps
and payout notifications skip any row whose owner is a test twin's principal; the lifecycle and
broadcast audiences exclude those principals. payment_links.send_invoice stays refused in test
mode: the response has no field that could say the email was not delivered. Evidence and tests:
docs/api/TEST-MODE-PROVIDERS.md §13.
No time-driven firing
No operation is dev-cron today — the bundled contract carries zero
x-swaps-test-mode: dev-cron values (corrected from a stale "three operations" claim, K11-6
fixer round 1, finding #6/#11). The category is specified for a future payment-link-reminder /
payroll-funding-poll build, but nothing has claimed it yet. The DEV cron jobs themselves do run: a
read-only check of DEV's cron.job on 2026-09-24 found payment-links-cron (every 15 minutes),
payout-notifications, crypto-subscriptions-emitter and the sandbox webhook worker active, and DEV
holds a Resend key. None of them emails anyone for a test twin's rows (see "No outbound email"
above), and no contract value promises that a schedule fires for a test object (design decision
D-3).
webhook_endpoints.*/webhook_deliveries.* being sandbox (routing only) is not the same claim
as delivery firing: a queued delivery is only actually sent by DEV's own sandbox webhook worker
cron job (migration 20260905120000, scheduled every minute) while DEV's api_v1.webhooks_delivery
flag is on. Both were on in DEV's read-only state of 2026-09-24 (TEST-MODE-PROVIDERS.md §13);
no test proves an end-to-end test-mode delivery yet.
Other git-tracked cron jobs could act on test rows in DEV: payment-links-cron (reminders),
crypto-subscriptions-emitter, payout-notifications and lifecycle-notify. Whether each is
scheduled on DEV has not been verified either. It does not matter for a test caller today, because
every operation that creates a row those jobs read is refused.
Credits
credit_checkouts.create is unavailable in test mode, not sandbox — it opens a real Stripe
payment for a credit pack, and there is no test purchase to substitute (design decision D-4,
pending the founder's sign-off on whether one should exist). credits.get/credit_events.list
are ALSO unavailable in the bundled contract today (corrected from a stale "Stripe test mode"
source description, finding #6) — no sandbox claim has been restored for either read yet, so
both still answer the platform-wide 503 once forwarding is on, same as the checkout itself.
Coverage by resource
Of the 164 routed operations, 9 are local, 15 are forward and 140 are refused. Each route's
disposition is exactly its contract value (full → local, sandbox → forward,
unavailable → refused), checked operation by operation in test_mode.test.ts. The 24
reachable operations are exactly test-mode-truth.test.ts's evidence list.
Test-mode events: GET /v1/events/stream is refused and never forwarded, because production
could not re-check a revoked sk_test_ key on every tick of a stream it only relays; GET /v1/events and GET /v1/events/{id} are unavailable too. Test-mode
events are not readable through /v1 today — no test object can be created yet; once creates are
restored, poll the forwarded per-object */events lists (GET /v1/payouts/{id}/events and its
siblings). DEV writes no event at all until DEV's api_v1.events flag is on; turning it on is an
operator step on DEV only, a prerequisite for webhook test events and replays there, and no
production flag changes.
| Test mode | Router disposition | What it means | Example resources |
|---|---|---|---|
full | local | No provider in the loop — the same handler, in production, on the test twin account | 9: api_keys.*, request_logs.list, account.get, accounts.list |
sandbox | forward | A real Bridge sandbox call, made by the DEV project | 15: webhook_endpoints.*/webhook_deliveries.*, the five *.events.list reads (payments, payment_links, payouts, payroll, orders) |
fixtures | forward | Reserved for a provider with no sandbox. No fixture exists in code today | Specified (screenings.reports.evidence_pdf.get, payment_sessions.pay_with_wallet) but neither is routed yet — no fixtures operation is reachable today |
dev-cron | forward | The object is readable and writable in DEV; nothing fires on a schedule (see above) | None — no operation currently claims this value (see above) |
unavailable | refused | 503 before any handler runs — either the operation itself refuses test mode (four operations, plus credit_checkouts.create), or K11 has not yet individually evidenced a claim for it | 140: quotes.create, orders.create, credit_checkouts.create, customers.create, capabilities.get, events.*, account.update, accounts.create, virtual_accounts.* reads, every payout, payroll, payment-link and wallet write (including payroll_runs.items.reissue_link), and every other operation not on test-mode-truth.test.ts's evidence list |
unavailable on an operation not in that evidence list is not a claim about the operation's own
code — it is A1-3's annotation freeze, meaning only that K11 has not restored a specific
full/sandbox/fixtures/dev-cron claim for it yet, each restoration landing with its own
test (test-mode-truth.test.ts's K11_EVIDENCE_LIST). The router
dispositions every such route as refused, so it answers 503 whether api_v1.test_mode is on
or off. Each operation in the
reference states its own x-swaps-test-mode value — read it there rather than
assuming from this table.
Ask your agent — once payment links reach test mode
Once payment_links.create/.activate earn their own sandbox restoration (both are still
unavailable — no claim is made here that they already forward), "Using my test key, walk through
creating and activating a payment link, then show me the response at each step" will be a safe
prompt to hand an agent, because nothing it touches could move real money. Until then every step
of that walkthrough returns 503 temporarily_unavailable, with test mode on.
scripts/api/test-mode-acceptance.ts runs a read-only walk — the public eligibility read, sent
without a key, then webhook endpoints and webhook deliveries, which the contract marks usable in
test mode, with a test key — printing a PASS/EXPECTED/SKIPPED/FAIL verdict and the request id per
step. The A5 baseline is the canonical host (api.swaps.app) with a test key: that run goes
through production's auth, fork and signed hop, which is the path a real caller takes. The script
also accepts the DEV project host, but that run skips the prod-to-dev signed hop and proves only
DEV's own handlers, so it is not the baseline. The customers, verification-link, payment-link and
payout steps, and capabilities, are unavailable, so the run reports them SKIPPED and never
sends them. On api.swaps.app the eligibility step answers 503 temporarily_unavailable, because
GET /v1/eligibility stays closed in production (see Providers & coverage);
the webhook steps should read PASS. A bare run scores every 503 temporarily_unavailable as FAIL
(K11-6 fixer round 1, finding #4/#9). --expect-dark scores each one as EXPECTED instead — a
webhook step's too — and prints that the run is not a baseline, so read each step's verdict, not
only the exit code.
What test mode's build already proves, mechanically (not "is designed to" — CI runs this)
- A live caller is never forwarded, and a test caller never reaches a live handler — swept over every route in the router's own table, not asserted by discipline.
- A blocked live-provider call is reported as
503 live_provider_blocked, not as an internal error; there is no fixture to fall back to. - No caller credential and no service-role key ever crosses the hop. The signed assertion carries an account id, a role and scopes already checked in production, and a nonce. The request itself crosses too; see "Test data" below.
Test data
What a forwarded test-mode request sends to DEV, as the code stands today (test_mode.ts):
- the signed assertion:
v,iss,livemode,account_id,account_public_id,caller_kind,role,scopes,plan,method,path,query,operation,swaps_version,request_id,idempotency_key,tsandnonce; - the query string and, for a write only, the request body, byte for byte;
- headers: the caller's
x-correlation-idandcf-rayas sent;x-request-id, which is the caller's ownx-request-idorx-client-request-id(at most 128 characters) or else a fresh id from production; the caller'sIdempotency-Keywhen sent; and, for a write, the caller's content type (application/jsonwhen none is sent). Production adds the assertion, its signature andSwaps-Version. No other caller header crosses.
DEV records no client IP for a forwarded request: its usage-log row is written with the IP left
empty (test_mode_inbound.ts), because the hop arrives from production, not from the caller.
Where it is stored: the Swaps development project, which is not production. Rows a forwarded request writes there (for example a webhook endpoint, a usage-log row, an idempotency record) have no retention window and nothing deletes them automatically. They are deleted on request: an operator erases the account's test-mode data, which removes the account's test webhook endpoints and deliveries, events, usage-log and idempotency rows, its sandbox principal and the test account itself (API-CANON §15). Do not submit real personal or bank data in test mode. Use made-up names, addresses and account numbers.
What test mode does not do
- It does not simulate every failure mode a live provider can produce — sandboxes and fixtures cover the happy path and the documented error cases, not every edge case a live integration might hit.
- It does not fire anything on a schedule yet (see "No time-driven firing" above) — a
dev-cronclaim describes an object's shape, not a running job. - There is no separate OAuth agent-token class — that claim was withdrawn (decision C4-D32, see
Authentication & keys). An agent authenticates with a business key, which
already carries its own test/live split (
sk_test_…/sk_live_…), so this is not a gap.
Next: Get started walks the whole loop end to end · Conventions for the shared rules every resource follows.