# 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 by
  `accounts.test_twin_account_id`, created implicitly the first time `POST /v1/api_keys` is called
  with `livemode: 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 operations `api_keys.*` and `request_logs.list` (credentials and the outer usage log
  are prod-only by design), plus `account.get` and `accounts.list`, which read the test twin
  account itself. A signed-in session in test mode lists only its test twins, never its live
  accounts. Their `market` comes only from the twin's declared `country` or the default,
  never from the owner's verification data. An `sk_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 an `sk_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:
  `language` and `support_contact` are `null`, `notification_preferences` is
  `{marketing: false, product_tips: true}` and `dashboard_preferences` is `{}`. They are never the
  owner's values. `caller` describes 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.create` and
  the two waitlists are `refused`: they write live account state. `account.update` also refuses, in its handler, any user-level field (language, preferences,
  support contact) from a test caller with `400 livemode_boundary` before any write.
  `accounts.create` also 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-login
  `409 account_already_exists` a 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 and `Idempotency-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 a `fixtures` value for a provider with no sandbox, but **no fixture exists in code today**: neither `fixtures` operation is routed, so nothing answers with one.
  Production passes through only an answer the sandbox executor produced. Anything else becomes `503 temporarily_unavailable` with `Retry-After: 60`; it is marked `sideEffectFree` only 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.
  create` earns it by design (below); every other operation whose contract value is `unavailable`
  is `refused` by its route too. So every operation the contract marks `unavailable` answers `503
  temporarily_unavailable` even with `api_v1.test_mode` on. Router and contract are tied by the
  strict parity test in `supabase/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](/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](/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](/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`, `ts` and `nonce`;
- the query string and, for a write only, the request body, byte for byte;
- headers: the caller's `x-correlation-id` and `cf-ray` as sent; `x-request-id`, which is the
  caller's own `x-request-id` or `x-client-request-id` (at most 128 characters) or else a fresh id
  from production; the caller's `Idempotency-Key` when sent; and, for a write, the caller's content
  type (`application/json` when none is sent). Production adds the assertion, its signature and
  `Swaps-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-cron`
  claim 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](/authentication)). 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](/get-started) walks the whole loop end to end · [Conventions](/conventions) for the shared rules every resource follows.
