# Swaps API docs > Complete documentation for Large Language Models --- ## Document: Test mode Test-mode contract, resource coverage and current dispatch limits. URL: /test-mode # 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/` (the dashboard build on the DEV backend), for a test key and a live key alike. Production keeps `https://swaps.app/pay/` 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. --- ## Document: Providers & coverage What this account can actually execute right now, rail by rail and country by country — read from the same resource the dashboard reads, never a separate marketing claim. URL: /providers-coverage # Providers & coverage Coverage is a resource, not a claim on a marketing page. `GET /v1/capabilities?product=…` answers "can this account do X" — read it instead of hard-coding an assumption about a corridor, a country or a payment method. A second endpoint, `GET /v1/eligibility`, is specified for "could an account in this country generally do X", but it is closed in production (see Eligibility below). ## Capabilities — what *this* account can do right now `GET /v1/capabilities?product=payment_links|payouts|payroll|buy_sell|wallet_bank` returns, per product, the corridors or rails this specific authenticated account is actually eligible for — not a static list of everything Swaps theoretically supports. Every corridor row states its own status honestly rather than a blanket "supported": | Field | Meaning | |---|---| | `lifecycle` | `public` (advertised), `preview` (soft-launched), or `blocked` | | `public_claim_status` | `live`, `unverified`, or `planned` — the honest gap between what's claimed and what's been probed | | `executable` | whether *this* account can execute today — a capability read is availability, not a green light to skip re-checking at the money boundary | | `blocked_reason` | present whenever `executable` is false, so an agent has something to act on instead of a bare no | `blocked_reason` is an open enum: a client that meets a value it does not know treats the corridor as not executable and shows the reason as text. Two values name an environment or account fact rather than a missing provider: | `blocked_reason` | Where | What it says | |---|---|---| | `relay_requires_tempo_mainnet` | `product=payment_links`, the `crypto_relay` corridor | The deployment settles on a Tempo test network (or the network cannot be resolved), and the payer page admits Relay only on Tempo mainnet. The corridor reads `not_enabled` with `source_chains: []` whatever the Relay switch says. `max_amount` is still published while the per-invoice cap is set. | | `collection_account_uses_currency` | `product=wallet_bank`, the `wallet_bank_virtual_account` corridor | A Payment links collection account already receives this currency into the same wallet, so a new wallet bank account would only hand that one back. The corridor reads `not_enabled` with `action: none`. `POST /v1/wallet/virtual_accounts` for that currency answers `409 conflict`. | For `product=payment_links` the answer also carries `fee`, the Swaps fee the payer is charged on top of the invoice (you receive the full invoice), read from the same constant the charge uses: `kind: "percentage"`, `bps: 100`, `applies_to: "payer"`, `rails: ["crypto_tempo", "crypto_relay"]`, `rounding` (`floor`) and `rounding_decimals` (`6`). A rail that is not in `rails` carries no fee claim from this object. The fee is a price, so it is published even while those corridors read `not_enabled`. See [Payment links](/products/payment-links#the-swaps-fee) for what the payer is asked for. There is also an unauthenticated **public projection** of capabilities (product-wide fee and corridor claims with no entitlement or per-account detail) for anyone building a comparison surface before a user signs up. > **Availability is not permission** > > A capability answering `executable: true` is not authorization to move money. The server re-checks eligibility at create time and again at the money boundary (funding, activation, execution) — routing output is never chained straight into execution. See [Agents & MCP → what a tool can never do](/agents-mcp#what-a-tool-can-never-do). ## Eligibility — what a country and account type could generally expect `GET /v1/eligibility` is `dark-flag` and closed in production: it answers `503 temporarily_unavailable`, and it stays closed until the public claims it makes are signed off. Do not build on it; for an authenticated account, read `GET /v1/capabilities?product=…` instead. What follows is the contract it will carry once it opens. `GET /v1/eligibility` (public: country + customer type) answers a coarser, pre-signup question — "if someone in this country opened this kind of account, what would likely work" — using the same eligibility engine the dashboard's own onboarding reads, with its own four honest states: - `likely_available` - `conditional` - `unavailable` - `unknown` `likely_available` and `conditional` are never collapsed into one "available" bucket — an agent told a flat "available" for something the engine only called *likely* would tell an end user something the engine itself won't promise. ## Providers behind a corridor A capability row can name the provider handling a corridor (`display_label`, `logo_url` where relevant) — the point of the resource is honesty about *whether* a corridor works, not brand attribution. Swaps fans a request out across multiple connected providers per product (banking rails, on/off-ramp providers, DEX and CEX liquidity) and returns the best executable route; which provider actually serviced a given order is visible on that order's own record, not guessed from the corridor list. ## Keeping this honest Coverage claims drift when a provider's own availability changes faster than a document does. The [KPI page](https://github.com/swapsapp/swaps/blob/main/docs/api/KPI.md) tracks **K12 — provider truth**: capability rows whose claimed status disagrees with the last live probe, target zero stale claims older than 24 hours. If a corridor this page or the `/v1/capabilities` response describes turns out wrong, that is a defect against K12, not a documentation nuance. Next: [Products](/products/payment-links) for what each product actually does with a resolved capability · [Agents & MCP](/agents-mcp) for how a tool reads this same resource. --- ## Document: Get started Prepare a business key and inspect account capabilities before creating a draft payment link. URL: /get-started # Get started Prepare a business key and inspect account capabilities before creating a draft payment link. > **Switched on resource by resource** > > Each `/v1` resource is switched on separately in production. A resource that is not switched on > answers `503 temporarily_unavailable` — read it as "not available yet", not as an outage, and read > `GET /v1/capabilities?product=` for what your account can do. The examples below are not an onboarding > guarantee. The [public MCP documentation](https://www.swaps.app/developers/skills/mcp-server) lists > the published integration surfaces. ## 1. Obtain a business key The account holder manages keys in Developers → API keys. Automatic issuance on signup is not promised. Supply an existing key through your client environment. **This guide needs a live key**: a `sk_test_` key runs only the operations [Test mode](/test-mode) lists — API-key, request-log and account reads on your test account, and webhook endpoints, webhook deliveries and the per-object event lists in the development project — and the payment-link calls below answer it `503 temporarily_unavailable`. Once enabled, read `GET /v1/capabilities?product=payouts` first, with a live key — `capabilities` is unavailable in test mode (see [Test mode](/test-mode)) — `product` is required, one of `payment_links`, `payouts`, `payroll`, `buy_sell`, `wallet_bank` — and proceed only when the account is authorized. ``` sk_live_51NxSupaLabs...9f2 ``` Keys are shown once. Rotate a lost key from Developers → API keys — support cannot read it back to you. ## 2. Create a payment link A link starts as a `draft`: nothing is charged and no rail goes live until you activate it. Every mutating request carries an `Idempotency-Key` so a retried call never double-creates the link — generate a fresh key per new link; reusing the same key within 24 hours replays the stored response instead of creating a second one — and `amount` is always a `Money` object — a minor-unit integer string plus `decimals`, never a float. An optional `Swaps-Version` header pins the call to a dated contract version; omit it and the key's own default applies, and every response echoes the version it was served under (see [Conventions](/conventions)). Name where the money lands when you draft the link, or `activate` refuses it with `400 settlement_destination_required`. `settlement_destination` points to a bank account or crypto address you already saved in the address book. `/v1/address_book` takes a dashboard session or an agent key, never a business key, so save the destination once under Addresses in the dashboard. No dashboard screen shows its `adr_…` id today: read it from `GET /v1/address_book` with an agent key or a dashboard session (the `id` field, `adr_` plus a UUID), then reuse it from your key. Without one, use the crypto-only path below. The other option is a crypto-only link paid out to your Swaps Wallet: send `accepted_rail_kinds: ["crypto"]` instead, on a **USD** link only, where the crypto-only capability is enabled for your account. Don't send both. Payment links are unavailable in test mode, so the samples use a live key; a `sk_test_` key gets `503 temporarily_unavailable` here. There is no published Swaps client library today — `packages/sdk-ts` (`@swaps/sdk`) is this repo's own internal, unpublished package, not something `npm install`able. Until one ships, cURL and plain `fetch` — a Node 18+ and browser built-in, no library to install — are what actually runs: ```shell title="cURL" curl https://api.swaps.app/v1/payment_links \ -X POST \ -H "x-api-key: sk_live_51NxSupaLabs...9f2" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: supalabs-2026-014" \ -H "Swaps-Version: 2026-09-04" \ -d '{ "title": "Design retainer — September", "amount": { "amount": "500", "currency": "USD", "decimals": 2 }, "payer_email": "billing@supalabs.dev", "settlement_destination": { "address_book_id": "adr_3f1c2e4a-8b6d-4f2a-9c1e-5d7b3a9e0f21" } }' ``` ```javascript title="fetch (Node 18+ / browser)" const res = await fetch('https://api.swaps.app/v1/payment_links', { method: 'POST', headers: { 'x-api-key': 'sk_live_51NxSupaLabs...9f2', 'Content-Type': 'application/json', 'Idempotency-Key': crypto.randomUUID(), // a NEW key per new link — reusing one replays the prior response 'Swaps-Version': '2026-09-04', // optional — pins the call to a dated contract version }, body: JSON.stringify({ title: 'Design retainer — September', amount: { amount: '500', currency: 'USD', decimals: 2 }, payer_email: 'billing@supalabs.dev', settlement_destination: { address_book_id: 'adr_3f1c2e4a-8b6d-4f2a-9c1e-5d7b3a9e0f21' }, }), }); if (!res.ok) { const { error } = await res.json(); throw new Error(`${error.type}/${error.code}: ${error.message}`); } const link = await res.json(); console.log(link.id, link.status); // pl_01J9ZK... draft ``` **201 Created — `payment_link` · `draft`** ```json { "id": "pl_01J9ZK3Q8M2F5A7C9E1G3H5J7K", "object": "payment_link", "livemode": true, "status": "draft", "title": "Design retainer — September", "memo": null, "invoice_number": null, "amount": { "amount": "500", "currency": "USD", "decimals": 2 }, "client_id": null, "expected_payer_type": "any", "allowed_rails": [], "payable_rails": [], "accepted_rail_kinds": [], "expires_at": null, "payer_email": "billing@supalabs.dev", "reminder_schedule": { "status": "off", "offsets_days": [], "sent": [] }, "settlement_kind": null, "settlement_destination": { "address_book_id": "adr_3f1c2e4a-8b6d-4f2a-9c1e-5d7b3a9e0f21" }, "items": [], "url": null, "created_at": "2026-09-04T14:02:11Z" } ``` ## 3. Open the link A draft has no `url`. Activating is the account holder's own compliance attestation — over the dashboard, or a signed `POST …/activate` — never something an agent does on their behalf (see [Payment links](/products/payment-links)). REST only: `activate` has no MCP tool. - `attestation_accepted` must be `true`; anything else is refused. - `url`, `payable_rails` and `settlement_kind` are set at that moment, never before. - `status` moves `draft → active` and the payer can open the link. ```shell title="cURL" curl https://api.swaps.app/v1/payment_links/pl_01J9ZK3Q8M2F5A7C9E1G3H5J7K/activate \ -X POST \ -H "x-api-key: sk_live_51NxSupaLabs...9f2" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: supalabs-2026-014-activate" \ -d '{ "attestation_accepted": true }' ``` A refused activation leaves the link a `draft`: | Answer | Meaning | |---|---| | `400 settlement_destination_required` | The draft names neither a `settlement_destination` nor `accepted_rail_kinds: ["crypto"]`. `PATCH` a destination onto it and activate again. | | `409 allowed_rails_invalid_for_currency` | The stored `allowed_rails` leaves no rail a payer could use. `error.details.offerable_rails` lists the ones that would work. | | `422 crypto_only_currency_not_usd` | A crypto-only link is not in USD. The Swaps Wallet rail pays out USD stablecoins with no FX step. | An account not yet eligible gets `403 permission_error` before the destination is checked; a crypto-only draft can also get `409 wallet_not_found` or `409 capability_unavailable`, and a second activate gets `409 conflict`. The guide's Minimal flow table gives each one's fix. The payer opens `url` (`https://swaps.app/pay/plk_…` on production). Its last path segment is the public session token that `GET /v1/payment_sessions/{token}`, `POST …/view` and `POST …/payments` take, with no key. The [Payment links guide](/products/payment-links#minimal-flow) walks through the payer side and the other refusals. ## 4. Go live Live access requires enablement, an authorized live key, [account verification](/products/customers) and the relevant capabilities. Changing the key prefix does not grant access. Confirm the supported operation and rail before making a live call. > Do not treat a documented test-mode contract as proof of a working end-to-end sandbox. See [Test > mode](/test-mode) for implementation limits. ## Ask your agent instead After enablement, an agent with an authorized key can inspect capabilities and prepare permitted drafts. The account holder creates the key and confirms any activation themselves. **Try the quickstart as a prompt** Paste this into an agent that already holds a Swaps live business key — capabilities are unavailable in test mode. > Using my existing business key, call GET /v1/capabilities?product=payment_links over REST — the MCP tool equivalent is list_capabilities (see the Agents & MCP page). If authorization or availability fails, stop and report the error. Otherwise summarize permitted actions; do not create or activate a payment link. Next: the [Payment links guide](/products/payment-links) · the full [error type table](/conventions#errors). --- ## Document: Events & webhooks One cross-domain event stream — polling, live SSE, and outbound signed webhook deliveries. All of it is implemented; two resource flags gate production traffic. URL: /events-webhooks # Events & webhooks > **On in production since 2026-10-08** > > Every operation on this page is implemented and `x-swaps-status: available` in the contract — this is not a design doc. Three resource flags gate it, and all three were switched on with dashboard v2 (2026-10-08): `api_v1.events` (the `/v1/events` reads and the stream), `api_v1.webhooks` (the `webhook_endpoints.*`/`webhook_deliveries.*` REST routes) and `api_v1.webhooks_delivery` (the fan-out trigger and the delivery worker — a separate switch, because registering an endpoint and actually delivering to it are different guarantees). The global `api_v1` flag gates the REST calls too. While any of these is off, the affected calls return `503 temporarily_unavailable`. **With `api_v1.webhooks` on and `api_v1.webhooks_delivery` off, endpoints can be registered but nothing is delivered to them** — `api_v1.webhooks_delivery` is what actually runs the worker. ## The event stream `GET /v1/events` is the single cross-domain, append-only outbox every other event-shaped read is a view over — `payment_links.events`, `payouts.events`, `payroll_run.events` and so on each read from the same table rather than keeping their own parallel history. ```json { "id": "evt_01J9...", "type": "payment_link.activated", "created_at": "2026-09-04T14:02:11Z", "livemode": false, "data": { "object": { "id": "pl_01J9...", "type": "payment_link" } }, "request": { "id": "req_01J9...", "idempotency_key": "supalabs-2026-014" }, "api_version": "2026-09-04" } ``` - `GET /v1/events` — list, filter by `type` or `object` (a resource id), cursor-paginated (see [Conventions](/conventions#pagination)). Poll with `?since=` only when a live connection is unavailable. - `GET /v1/events/{id}` — one event, read-only — events are never created through the API. - `GET /v1/events/stream` — Server-Sent Events, same auth and envelope as the REST list, one frame per event. This is the live channel for **every** client, first-party dashboard included — there is no private realtime side-door. Authenticate with `Authorization: Bearer …` or `x-api-key`, opened with `fetch()` against a readable stream — never native browser `EventSource`, which cannot set a header. On a dropped connection, reconnect with `Last-Event-ID` set to the last id you received; the stream resumes immediately after it, never from the start. A gap wider than the retention window is not silently bridged — fall back to `GET /v1/events?since=`. ## What reaches webhooks and /v1/events today `type` is one catalogue read from two sides. On a **request** — the `type` filter of `events.list`, or `event_types` on `webhook_endpoints.create`/`.update` — it is closed: an unknown or retired name is `400 invalid_request`. On a **response** — `Event.type`, a delivery's `type`, `WebhookEndpoint.event_types` — it is open: treat an unrecognized name as an opaque string and never fail to parse it, because the catalogue grows within `/v1` and a delivery may carry a type added after your client was generated. Every member is marked `live` (written to the `api_events` outbox today) or `catalogued` (not written to the outbox yet). Subscribing to a catalogued type is accepted, and **it is never delivered to a webhook endpoint and never listed by `/v1/events` or `/v1/activity` until its outbox allowlist ships**. A per-resource event list (`payouts.events.list`, `payroll_runs.events.list`) reads the product's own ledger and may still show a catalogued type. The contract carries the same marking as `x-swaps-event-status` on `EventType`. No history before 2026-09 is backfilled for any type. {/* BEGIN GENERATED event-type-status — scripts/openapi/normalize.mjs from EVENT_PAYLOAD_ALLOWLIST; do not edit */} | Family | Live — in the event outbox | Catalogued — not in the outbox, never delivered or in /v1/events | |---|---|---| | `payment_link` | `payment_link.created`, `payment_link.activated`, `payment_link.viewed`, `payment_link.paid`, `payment_link.settled`, `payment_link.partially_paid`, `payment_link.refunded`, `payment_link.cancelled`, `payment_link.expired`, `payment_link.reminder_sent`, `payment_link.reminder_schedule_updated` | — | | `payment` | `payment.created`, `payment.marked_sent`, `payment.awaiting`, `payment.paid`, `payment.settled`, `payment.underpaid`, `payment.overpaid`, `payment.unmatched`, `payment.expired` | `payment.detected`, `payment.processing` | | `subscription` | `subscription.created`, `subscription.paused`, `subscription.resumed`, `subscription.cancelled`, `subscription.invoice_issued`, `subscription.invoice_overdue`, `subscription.invoice_paid` | — | | `payout` | `payout.created`, `payout.funded`, `payout.cancelled`, `payout.marked_sent`, `payout.created_as_replacement`, `payout.beneficiary_added` | `payout.awaiting_funds`, `payout.funds_received`, `payout.processing`, `payout.paid`, `payout.settled`, `payout.failed`, `payout.returned`, `payout.paid_with_shortfall`, `payout.replaced`, `payout.configuration_committed` | | `payroll_run` | `payroll_run.created`, `payroll_run.approved`, `payroll_run.cancelled`, `payroll_run.funding_verified`, `payroll_run.execution_started`, `payroll_run.completed`, `payroll_run.partial`, `payroll_run.failed` | `payroll_run.funding_instructions_requested`, `payroll_run.funding_instructions_verified`, `payroll_run.funding_instructions_failed`, `payroll_run.funding_instructions_blocked`, `payroll_run.underfunded`, `payroll_run.execution_requested`, `payroll_run.execution_blocked` | | `payroll_item` | — | `payroll_item.destination_updated`, `payroll_item.destination_changed`, `payroll_item.payout_queued`, `payroll_item.payout_processing`, `payroll_item.payout_paid`, `payroll_item.payout_failed`, `payroll_item.payout_returned` | | `payroll_template` | — | `payroll_template.created` | | `deposit_intent` | — | `deposit_intent.created`, `deposit_intent.source_detected`, `deposit_intent.bridging_started`, `deposit_intent.settled`, `deposit_intent.failed`, `deposit_intent.expired`, `deposit_intent.recovery_started`, `deposit_intent.manual_recovery_required` | | `send_intent` | — | `send_intent.created`, `send_intent.source_submitted`, `send_intent.bridging_started`, `send_intent.settled`, `send_intent.failed`, `send_intent.expired` | | `offramp_intent` | — | `offramp_intent.created`, `offramp_intent.funds_received`, `offramp_intent.payment_submitted`, `offramp_intent.payment_processed`, `offramp_intent.refunded`, `offramp_intent.refund_failed`, `offramp_intent.canceled` | | `virtual_account` | — | `virtual_account.created`, `virtual_account.deactivated`, `virtual_account.reactivated`, `virtual_account.deposit_received` | | `wallet` | — | `wallet.created` | | `conversion` | — | `conversion.built`, `conversion.submitted`, `conversion.settled`, `conversion.failed` | | `customer` | `customer.verification_state_changed`, `customer.rejected`, `customer.requirements_updated` | — | | `capability` | — | `capability.blocked`, `capability.unblocked` | | `order` | `order.created`, `order.status_changed`, `order.failed`, `order.refunded`, `order.cancelled` | — | | `screening` | — | `screening.completed`, `screening.risk_elevated` | | `account` | — | `account.access_state_changed` | | `address_book` | — | `address_book.entry_rechecked` | | `credit` | — | `credit.consumed`, `credit.purchased` | | `test` | `test.ping` | — | | `webhook_endpoint` | `webhook_endpoint.disabled` | — | {/* END GENERATED event-type-status */} Who produces the live types: - `payment_link.*`, `payment.*` — the payment-link service layer: merchant mutations, payer rail selection, the Bridge webhook reducer, the expiry/reminder cron. - `payment.underpaid`, `payment.overpaid`, `payment.unmatched` — also the Tempo payment watcher (`crypto_tempo`/`crypto_relay`). `payment.unmatched` fires when a payment becomes `unmatched`, and `data.object.unmatched_reason` says why: `partial_before_cancel` when the merchant cancelled the link after the payer had sent part of the amount (the cancel emits it; a failed emit does not block the cancel), or `deposit_after_close` when money is first seen at the address of a payment that had already closed unpaid (expired, or its link cancelled before any money was seen). It is an open enum: treat an unknown value like `null`. The same field is on the payment itself (`GET /v1/payments/{id}`). A second deposit to an already-paid payment is recorded for support and not published on /v1; a deposit matching no payment is recorded for support. - `payout.*` — `appendPayoutEvent`, from an api-v1 mutation or a dashboard/admin action. - `payroll_run.*` — `payroll/lib.ts`'s `appendEvent`, for any caller. - `subscription.*` — the crypto subscriptions service. - `order.*` — `emitOrderOutboxEvent`, from the Bridge-native order paths and the Bridge webhook reducer. - [`customer.*`](/products/customers) — `emitCustomerOutboxEvent`, from the Bridge customer webhook handler. - `test.ping` — `webhook_endpoints.send_test_event`, delivered only to the endpoint under test, never fanned out. - `webhook_endpoint.disabled` — `api-webhooks-worker`, the moment an endpoint's auto-disable counter trips. ## Webhook endpoints Registered destinations, one https URL per endpoint. Callers: a business key, or a first-party bearer session (dashboard or [agent](/agents-mcp) — an agent authenticates with a business key like any other caller; the OAuth agent class this used to imply never existed, decision C4-D32, see [Authentication](/authentication)). `create`, `update`, `delete`, `rotate_secret` and `send_test_event` additionally require a **bearer** caller to be the account's `owner` — the same bar `api_keys` management uses, since a signing secret is account-level credential material; a business key needs no further gate. `list`/`get` (both resources) and `replay` carry no owner requirement. Every operation uses the `developers.read`/`developers.write` scope pair — not a `webhooks.*` scope. Test mode: `full` — a test-mode caller only ever sees, and can only ever register, test-mode endpoints. | Operation | Method & path | Scope | |---|---|---| | `webhook_endpoints.list` | `GET /v1/webhook_endpoints` | `developers.read` | | `webhook_endpoints.create` | `POST /v1/webhook_endpoints` | `developers.write` | | `webhook_endpoints.get` | `GET /v1/webhook_endpoints/{id}` | `developers.read` | | `webhook_endpoints.update` | `PATCH /v1/webhook_endpoints/{id}` | `developers.write` | | `webhook_endpoints.delete` | `DELETE /v1/webhook_endpoints/{id}` | `developers.write` | | `webhook_endpoints.rotate_secret` | `POST /v1/webhook_endpoints/{id}/rotate_secret` | `developers.write` | | `webhook_endpoints.send_test_event` | `POST /v1/webhook_endpoints/{id}/send_test_event` | `developers.write` | | `webhook_deliveries.list` | `GET /v1/webhook_deliveries` | `developers.read` | | `webhook_deliveries.get` | `GET /v1/webhook_deliveries/{id}` | `developers.read` | | `webhook_deliveries.replay` | `POST /v1/webhook_deliveries/{id}/replay` | `developers.write` | ``` POST /v1/webhook_endpoints { "url": "https://example.com/hooks/swaps", "event_types": ["payment_link.paid", "payout.settled"] } ``` `event_types` omitted or `[]` means "every type this account can ever emit, present and future." An account may register at most **20 endpoints**; `create` past the limit is `409 webhook_endpoint_limit_exceeded`. **URL rules.** `url` must be `https://` — a non-`https` scheme is `400 webhook_url_not_https`. Everything else that makes a URL unusable for a webhook — an embedded `user:pass@` credential, a `localhost`/`.local`/`.internal`/`.arpa` literal, an IP literal in a private/loopback/link-local/CGNAT/reserved range (including IPv4-mapped and NAT64-embedded IPv6 forms), or a hostname that resolves to one of those ranges, or fails to resolve at all — is `400 webhook_url_not_allowed`, checked again by the delivery worker immediately before every send. This narrows, not closes, the SSRF window — see [Verifying a delivery](#verifying-a-delivery) below for the one residual risk this design does not defend against. **The secret.** `create` and `rotate_secret` return `secret` — the full signing secret in cleartext — exactly once, in that one response; every later read returns `secret_prefix` only (e.g. `whsec_ab12cd`, safe to display anywhere). A replayed `Idempotency-Key` on `create`/`rotate_secret` gets the identical body back with `secret` overwritten to `null` — never the cleartext twice, even from the idempotency store. `rotate_secret` keeps the OLD secret's hash live as `previous_secret_hash` for a **24-hour overlap window**: every delivery signed in that window carries a signature for BOTH the new and the old key, so a receiver that has not yet picked up the new secret keeps validating instead of failing every delivery from the instant of rotation. **Auto-disable.** An endpoint that racks up **3 consecutive fully-`exhausted` deliveries** (any `succeeded` delivery resets the counter to 0) is disabled automatically: `enabled` becomes `false`, `disabled_reason` becomes `auto_disabled_repeated_failures`, and a `webhook_endpoint.disabled` event is emitted (readable via `/v1/events` — never delivered to the endpoint it just disabled). `PATCH .../{id}` with `{"enabled": true}` re-enables it and resets the counter; `rotate_secret` does not re-enable a disabled endpoint. ## Deliveries and replay A delivery's body carries `id`, `type`, `created_at`, `livemode`, `api_version` and `data` — the same values as the REST `Event`, minus `object` and `request`, which are not sent on a delivery. The contract publishes it as the `WebhookEvent` schema under OpenAPI's `webhooks`, one entry per event family, so a generated client gets a typed payload: in `@swaps/sdk`, `generated.WebhookEventSchema.parse(JSON.parse(rawBody))` after the signature check below, and the `webhooks` type for the static shape. `GET /v1/webhook_deliveries` lists every attempted-or-scheduled delivery across the account's endpoints, filterable by `endpoint_id` or `status`; `GET /v1/webhook_deliveries/{id}` reads one. Fields: | Field | Meaning | |---|---| | `attempt` | How many delivery attempts this row has made so far. | | `status` | `pending` (never attempted), `failed` (a retry is scheduled at `next_attempt_at` — automatic, no action needed), `succeeded` (terminal), or `exhausted` (every scheduled attempt failed — terminal; `replay` it explicitly). | | `next_attempt_at` | When the next automatic retry is due, or `null`. | | `response_status` | The HTTP status your endpoint returned, or `null` if the attempt never got one (timeout, DNS/SSRF refusal, connection error). | | `response_ms` | Round-trip time in milliseconds. The response **body** is never stored. | | `error` | A short machine-readable reason for the most recent failure (e.g. `timeout`, `connection_error`, `non_2xx_response`, or `url_not_allowed:` — the SSRF/DNS refusal carries its own suffix, e.g. `url_not_allowed:dns_resolution_failed`) — never your endpoint's response body. | | `delivered_at` | Set once, the moment `status` first becomes `succeeded`. | A non-2xx response — including a 3xx: **the worker never follows a redirect**, a redirect is recorded as a failed attempt, not a success — a connection failure, or a 10-second timeout schedules a retry on this fixed schedule: | Attempt that just failed | Next retry after | |---|---| | 1 | 1 minute | | 2 | 5 minutes | | 3 | 30 minutes | | 4 | 2 hours | | 5 | 12 hours | | 6 | 24 hours | | 7 | none — `exhausted` | `POST /v1/webhook_deliveries/{id}/replay` enqueues a **new** delivery row for the same event and the same endpoint — the original row is never mutated. Safe to call repeatedly. Refused with `409 webhook_endpoint_disabled` when the endpoint is currently disabled, or a generic `409 conflict` when the endpoint's *current* `event_types` no longer include this event's type (the owner may have narrowed subscriptions since the original delivery went out). `POST /v1/webhook_endpoints/{id}/send_test_event` mints one synthetic `test.ping` event and queues exactly one delivery to that one endpoint. It answers honestly rather than silently accepting work nothing will run: - `409 webhook_endpoint_disabled` — the endpoint is disabled. - `503 temporarily_unavailable` — `api_v1.webhooks_delivery` is off (a queued test would sit forever with nothing to process it). - `503 temporarily_unavailable` — `api_v1.events` is off (there is no outbox to write the test event to). ## Verifying a delivery Every delivery carries a `Swaps-Signature` header: ``` Swaps-Signature: t=1757030400,v1=5257a869e7bfc7fd6f6e3f5b3ee5e3f... ``` - The signed string is the **exact raw bytes** of the request body you received, never a re-serialized or re-indented copy: `"."`. - The HMAC key is the raw 32 bytes of `SHA-256(UTF8(secret))` — the same digest Swaps stores hex-encoded as the endpoint's `secret_hash` — never the 64-character hex string itself, and never the secret's own bytes. This is the one non-obvious step: hashing the cleartext secret first, then using that raw digest as the HMAC-SHA256 key. - `v1` is `hex(hmac-sha256(that key, "."))`. - Compare in constant time. Reject a `t` more than **300 seconds** (`DEFAULT_SIGNATURE_TOLERANCE_SECONDS`) from your own clock, either direction — replay protection. - **During a `rotate_secret` overlap window, one header carries two `v1` tokens** — one signed with the new secret, one with the old. Accept a match against **any** `v1` token present, not only the first. - **On failure, reject the request and do not process the body.** A 3xx response from your endpoint is never treated as success — the worker does not follow redirects. ```javascript title="verify-signature.js (Node.js, no dependencies)" const crypto = require('crypto'); /** * Verifies a Swaps-Signature header against the raw request body and the * cleartext secret shown once at create/rotate_secret. Reject on `false` — * do not process the body. */ function verifySwapsSignature(secret, header, rawBody, opts = {}) { const toleranceSeconds = opts.toleranceSeconds ?? 300; const now = opts.now ?? Math.floor(Date.now() / 1000); let timestamp = null; const signatures = []; for (const part of header.split(',')) { const eq = part.indexOf('='); if (eq < 0) continue; const key = part.slice(0, eq).trim(); const value = part.slice(eq + 1).trim(); if (key === 't') { const parsed = Number(value); if (Number.isFinite(parsed)) timestamp = parsed; } else if (key === 'v1' && value) signatures.push(value); } if (timestamp === null || signatures.length === 0) return false; if (Math.abs(now - timestamp) > toleranceSeconds) return false; // The signing KEY is the raw 32-byte SHA-256 digest of the cleartext // secret — never the hex-encoded string, and never the secret's own bytes. const signingKey = crypto.createHash('sha256').update(secret, 'utf8').digest(); const expected = crypto .createHmac('sha256', signingKey) .update(`${timestamp}.${rawBody}`, 'utf8') .digest('hex'); // A header carries more than one v1 token during a rotate_secret overlap // window (24h) — accept a match against ANY of them. return signatures.some( (sig) => sig.length === expected.length && /^[0-9a-f]+$/i.test(sig) && crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(sig, 'hex')) ); } module.exports = { verifySwapsSignature }; ``` ```python title="verify_signature.py (Python, standard library only)" import hashlib import hmac import re import time def verify_swaps_signature(secret, header, raw_body, tolerance_seconds=300, now=None): """Reject on False — do not process the body.""" timestamp = None signatures = [] for part in header.split(","): if "=" not in part: continue key, _, value = part.partition("=") key, value = key.strip(), value.strip() if key == "t": try: timestamp = int(value) except ValueError: return False elif key == "v1" and value and re.fullmatch(r"[0-9a-fA-F]+", value): signatures.append(value) if timestamp is None or not signatures: return False if now is None: now = int(time.time()) if abs(now - timestamp) > tolerance_seconds: return False # The signing KEY is SHA-256(secret) — never the secret's own bytes. signing_key = hashlib.sha256(secret.encode("utf-8")).digest() message = f"{timestamp}.{raw_body}".encode("utf-8") expected = hmac.new(signing_key, message, hashlib.sha256).hexdigest() # A header carries more than one v1 token during a rotate_secret overlap # window (24h) — accept a match against ANY of them. return any(hmac.compare_digest(expected, candidate) for candidate in signatures) ``` Prove your own implementation against this fixed vector before pointing it at a live endpoint — generated with `signWebhookPayload` (`packages/contracts-api/webhook_signature.ts`), the exact function the delivery worker signs with: ```json title="Test vector (generated with signWebhookPayload)" { "secret": "whsec_test_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d", "body": "{\"id\":\"evt_test123\",\"type\":\"test.ping\",\"data\":{\"object\":{\"id\":\"whe_test456\",\"object\":\"webhook_endpoint\"}}}", "timestamp": 1735689600, "header": "t=1735689600,v1=c371d83d3c227120d0c5069fdc442fdca9121fcb798018105209d0ea9395d5d2" } ``` `verifySwapsSignature(secret, header, body, { now: timestamp })` returns `true` against this vector; change one character of `body` and it returns `false`. > **DNS rebinding — an honest, open residual risk** > > Both the create/update-time URL check and the delivery-time check resolve DNS and reject a private/reserved destination, but the actual delivery `fetch()` performs its own, independent DNS lookup a moment later. A hostile or compromised resolver can answer the check with a public address and the `fetch()` with a private one — there is no way to pin the specific IP a prior resolution returned. This narrows the SSRF window; it does not close it. Closing it fully needs an egress proxy or a network-level allowlist, not a smarter application check — tracked as a follow-up, not shipped here. Next: [Agents & MCP](/agents-mcp) for how a tool call surfaces the same error envelope · [Changelog](/changelog) for what shipped when. --- ## Document: Errors Every `/v1` error code, generated from the additive registry — one anchor per code, matching every response's `doc_url`. URL: /errors # Errors Generated from `packages/contracts-api/errors.ts`'s `ERROR_CODES` by `scripts/openapi/normalize.mjs`, as part of `pnpm run api:contract` — never hand-edited. Every error envelope's `doc_url` (see [Conventions § Errors](/conventions#errors)) points at this page, one anchor per code a `/v1` caller can actually receive: `https://docs.swaps.app/errors#code`. A code, once shipped, is additive-only — never renamed or removed. The status shown per code is its `type`'s canonical status, not always the literal status a legacy pre-`/v1` handler answers for that same code today. A code registered only to satisfy an internal invariant but never returned to a caller is listed under its type's "Internal label, never returned" section instead. | `type` | HTTP | Agent recovery | |---|---|---| | `invalid_request` | 400 / 405 / 413 / 422 | Fix the argument and retry. | | `authentication_error` | 401 | Re-authorize; do not retry as-is. | | `permission_error` | 403 | Wrong actor or scope — call as the right one. | | `not_found` | 404 | The id is not visible to this caller — also the cross-account answer. | | `conflict` | 409 | State moved — re-read, then decide. | | `idempotency_error` | 409 | Same key, different body. | | `rate_limit_error` | 429 | Back off; `Retry-After` is set. | | `capability_unavailable` | 409 | Do **not** retry — this corridor or product cannot serve it. | | `temporarily_unavailable` | 503 | A kill switch is thrown, or a dependency is out; retry after `Retry-After`. | | `quota_exhausted` | 402 | An allowance is spent — backing off does not restore it; upgrade or buy credits. | | `provider_error` | 502 / 503 | Upstream failed; retry only where stated. | | `api_error` | 500 | Ours — open a support ticket with the request id. | ## Terms acceptance Legal acceptance is disabled until a published revision and action schedule are explicitly activated. For a covered new live action, `409 terms_acceptance_required` carries `details.revision` and `details.action`. Ask the person using Swaps to review the terms; only the account owner can accept on behalf of an account. An API key, MCP tool or agent cannot create a receipt. Privacy notices, optional cookies, provider agreements and payment authorization remain separate. The first coverage is new payment links/activation, subscriptions/resumption, payouts, payroll runs, wallet send/deposit intents and orders. This is not a claim that every legacy or guest route is gated. Reads, cancellations, recovery and completed idempotent responses retain their existing paths. Test mode does not accept live terms. A legal refusal occurs before the new operation's handler and releases its new idempotency reservation. After acceptance, retry the same unchanged request with the same key. `503 legal_acceptance_unavailable` means verification failed, not that acceptance is waived; respect `Retry-After`. For any ambiguous operation result, reconcile the object or events before considering a new key. ## Type `invalid_request` ### `account_holder_unavailable` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `allowed_rails_invalid_for_settlement` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `amount_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `beneficiary_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `bridge_execute_rejected` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `confirmation_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `crypto_only_currency_not_usd` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `deposit_amount_below_minimum` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `destination_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `destination_rail_indeterminate` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `destination_rail_keyless` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `fiat_rail_mismatch` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `funding_method_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `iban_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `idempotency_key_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `idempotency_key_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `individual_amount_above_limit` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `individual_amount_unverifiable` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `individual_rail_unavailable` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `invalid_last_event_id` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `invalid_request` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `item_currency_mismatch` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `item_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `items_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `items_too_many` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `link_expired` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `link_not_payable` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `livemode_boundary` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `method_not_allowed` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: agent_api, api_v1 ### `mixed_currency_not_supported` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `mode_mismatch` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `no_payable_rail` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `owner_name_mismatch` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `p2p_amount_above_limit` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `pay_period_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `payer_type_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links, payouts ### `payer_type_mismatch` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payload_too_large` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `payout_refund_address_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_refund_address_not_allowed` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_refund_address_unsupported` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_source_chain_missing` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payroll_item_limit_exceeded` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `payroll_run_limit_exceeded` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `rail_not_allowed` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `refund_address_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `refund_address_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_account_holder_missing` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_conflicts_with_destination` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_destination_provider_refused` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_destination_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_details_incomplete` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_intent_conflict` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `settlement_rail_unsupported` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `source_tx_failed_onchain` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `source_tx_mismatch` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `source_tx_wrong_sender` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `stablecoin_invalid` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `tempo_settlement_currency_not_usd` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `tempo_via_bridge_not_enabled` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `travel_rule_counterparty_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `travel_rule_originator_incomplete` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `travel_rule_proof_required` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `unsupported_route` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `unsupported_version` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `wallet_network_not_supported` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `wallet_not_provisioned` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `webhook_url_not_allowed` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `webhook_url_not_https` 400 / 405 / 413 / 422 — `invalid_request`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ## Type `authentication_error` ### `authentication_error` 401 — `authentication_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `invalid_api_key` 401 — `authentication_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: agent_api, api_v1 ## Type `permission_error` ### `account_inactive` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `bridge_customer_missing` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `bridge_wallet_address_unavailable` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `bridge_wallet_missing` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `deposits_restricted` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `destination_blocked` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `eea_kyc_required` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `ip_not_allowed` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: agent_api, api_v1 ### `kyc_not_approved` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `missing_address_data` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `no_base_endorsement` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `no_settlement_endorsement` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `payer_bridge_customer_missing` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payer_customer_missing` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `payroll_verified_customer_required` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `permission_error` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, core ### `requirements_outstanding` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `role_denied` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `scope_denied` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: agent_api, api_v1 ### `tos_not_accepted` 403 — `permission_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ## Type `not_found` ### `not_found` 404 — `not_found`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 #### Internal label, never returned Registered to satisfy the "every thrown code is in the registry" invariant, but always remapped or folded to a different code before a `/v1` response leaves the boundary — never appears on the wire as this code. ### `external_account_not_found` ### `payout_receipt_not_ready` ## Type `conflict` ### `account_already_exists` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `allowed_rails_invalid_for_currency` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `cancel_unavailable` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `checkout_expired` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `conflict` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, core ### `conversion_source_tx_already_recorded` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `customer_already_exists` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `customer_mapping_stale` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `customer_type_conflict` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `funding_method_locked` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `funding_not_verified` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `holder_selection_required` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `legacy_wallet_read_only` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `no_pending_destination` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `payment_in_progress` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `payment_link_pay_in` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `payout_funding_instructions_not_ready` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `payout_not_fundable` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_refund_address_locked` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_source_chain_locked` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_transfer_missing` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payroll_run_funding_instructions_not_ready` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `pending_destination_changed` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `provider_adapter_disabled` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `rail_not_ready` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `receipt_not_available` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `recipient_destination_not_ready` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `requote_required` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `reusable_bridge_template` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `run_has_no_rows` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payroll ### `send_intent_payout_closed` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `source_tx_already_recorded` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `source_tx_not_yet_confirmed` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `wallet_already_exists` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `wallet_balance_short` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `wallet_funding_claimed` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts, wallet ### `wallet_funding_in_flight` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `wallet_funding_pending` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `wallet_funding_unavailable` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `wallet_not_found` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `webhook_endpoint_disabled` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `webhook_endpoint_limit_exceeded` 409 — `conflict`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ## Type `idempotency_error` ### `idempotency_error` 409 — `idempotency_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `idempotency_failed` 409 — `idempotency_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `idempotency_in_progress` 409 — `idempotency_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ## Type `rate_limit_error` ### `rate_limit_exceeded` 429 — `rate_limit_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: agent_api, api_v1, payment_links ## Type `capability_unavailable` ### `account_email_unavailable` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `accounts_not_enabled` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `capability_unavailable` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, core ### `conversion_no_liquidity` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `conversion_unavailable_single_token_network` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `destination_rail_unsupported` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `external_account_address_unavailable` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `external_account_currency_mismatch` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `external_account_rail_mismatch` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `network_not_supported` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `offramp_quote_unpriceable` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `offramp_route_unavailable` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `provider_not_supported` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `provider_paused` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `terms_acceptance_required` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `virtual_account_holder_not_verified` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `virtual_account_requirements_outstanding` 409 — `capability_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ## Type `temporarily_unavailable` ### `beneficiary_commit_pending` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `crypto_relay_flag_read_failed` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `customer_status_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `destination_check_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `destination_risk_unverifiable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links, payouts, payroll ### `eligibility_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `legal_acceptance_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `mail_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `named_payout_config_commit_pending` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payer_type_unverifiable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_cancel_commit_pending` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_cancel_retryable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_funding_commit_pending` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_fx_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_mark_sent_failed` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_quote_refresh_failed` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_replacement_failed` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_source_quote_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `payout_transfer_recovery_pending` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `rail_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `relay_route_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `source_tx_record_failed` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `summary_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `temporarily_unavailable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, core ### `wallet_funding_cancel_failed` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ### `wallet_state_unverifiable` 503 — `temporarily_unavailable`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payouts ## Type `quota_exhausted` ### `quota_exhausted` 402 — `quota_exhausted`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, core ## Type `provider_error` ### `collection_va_payee_name_missing` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `conversion_no_steps` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `live_provider_blocked` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `provider_instruction_invalid` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `relay_quote_failed` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ### `relay_quote_shortfall` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, payment_links ### `send_intent_no_steps` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1 ### `test_mode_unsupported` 502 / 503 — `provider_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: api_v1, wallet ## Type `api_error` ### `internal_error` 500 — `api_error`'s canonical status (RESOURCE-MODEL §0.7, not always the literal status a pre-`/v1` handler answers) · seen on: agent_api, api_v1 #### Internal label, never returned Registered to satisfy the "every thrown code is in the registry" invariant, but always remapped or folded to a different code before a `/v1` response leaves the boundary — never appears on the wire as this code. ### `run_item_totals_failed` ### `run_items_list_truncated` --- ## Document: Environments The base URL to send a request to, the /v1 path convention, and how the key prefix relates to it — separate from test mode. URL: /environments # Environments An **environment** is the base URL a request goes to — which project's database and providers it reaches. This is a different question from **test mode** (the `sk_live_`/`sk_test_` key prefix): see [Test mode](/test-mode) for what that prefix does and does not do yet. This page answers only "where do I send the request". ## Base URL | Environment | Base URL | Status | |---|---|---| | Production | `https://api.swaps.app/v1` | The only base URL this reference publishes. Each resource runs behind its own operator switch; while a resource's switch is off, its operations answer `503 temporarily_unavailable`. | | Development | Not published here | A separate, non-production project, with the Bridge sandbox behind it. Issued out of band, together with a development key, when the account team grants one — not self-service, and not covered by any availability commitment. | There is exactly one server in the published OpenAPI contract (`servers` in the generated reference). If you were handed a development base URL directly, it is a different host from the one above — treat it as internal-use, not something to build a public integration against. ## Hosted payer pages A payment link's `url` opens the payer page of the project that holds the link: | Project | Payer page | Who gets it | |---|---|---| | Production | `https://swaps.app/pay/` | Live keys. A test key's link has no page here, so its `url` is `null`. | | Development | `https://staging.swaps.app/pay/` | Live and test keys on the development project. | The host comes from each project's own configuration, not from the code. Until a project's host is configured, `url` is `null`: no address is guessed, and a development link never gets a production URL, where its token would not resolve. Payer e-mails (invoice, reminder, payment nudge) do not read this setting yet: their `/pay` links still follow the project's configured origin and can differ from `url`. Share `url` itself when the two must match. ## The `/v1` path convention The base URL already carries the version segment: production's base URL is `https://api.swaps.app/v1`, so every path in the [reference](/reference) — `/payment_links`, `/capabilities`, `/account` — is relative to it, never repeated. A full request URL is the base URL followed directly by the path, e.g. `https://api.swaps.app/v1/payment_links` (see the [Get started](/get-started) quickstart for a runnable example). ## Key prefix and environment `sk_live_…` and `sk_test_…` are not something you choose per request — the prefix is fixed for a given key at the moment it is issued, because it is fixed for the *account* the key belongs to: every account carries its own `livemode` (set once, at creation), and a key can only be minted in the mode that matches its account. This is enforced twice, not just documented: a database trigger refuses to write an `api_keys` row whose `mode` disagrees with its account's `livemode`, and the request-time auth check re-derives the expected mode from the account and compares it again before honoring the key. A mismatch — a key that somehow disagrees with its own account — is `401 invalid_api_key`, never a silent downgrade to the other mode and never a partial success. Asking for a key in the other mode is resolved at issuance, not at use: from a live account, `POST /v1/api_keys` with `livemode: false` mints the key on the account's test twin, creating the twin the first time (see [Test mode](/test-mode)); from a test twin, `livemode: true` answers `400 mode_mismatch` (`param: mode`). Once the key authenticates, its account's mode — which the prefix always matches — decides how the request runs. `sk_live_…` runs in production against live providers. `sk_test_…` runs as the account's test twin, on the same base URL: API-key, request-log and account reads run in production; webhook endpoints, webhook deliveries and the per-object event lists are forwarded to the development project over a signed hop; and every other operation a key may call answers `503 temporarily_unavailable`. What the base URL does decide is where the key is looked up at all: a key is a row in one project's database, so it authenticates only against the base URL of the project that issued it — a development key sent to `https://api.swaps.app/v1` fails authentication like any unknown key, and a production key sent to a development base URL does the same. See [Test mode](/test-mode) for exactly what is and is not implemented. Next: [Test mode](/test-mode) for the key-prefix contract in full · [Get started](/get-started) for a runnable request against the published base URL. --- ## Document: Conventions Money, idempotency, versioning, pagination and the one error envelope every resource shares. URL: /conventions # Conventions The rules every `/v1` resource follows, stated once (RESOURCE-MODEL §0) and never re-argued per resource. ## Money A money value is always an **object**, never a bare number: ```json { "amount": "500", "currency": "USD", "decimals": 2 } ``` `amount` is a minor-unit integer **as a string**; `decimals` states the scale so a client never re-derives it (USDC is `decimals: 6`). A float never appears on a money path. Basis points, percentages and block counts stay plain integers and are not `Money` objects. One stated exception: `POST /v1/quotes`'s `from_amount`/`to_amount` are bare decimal strings in major units (`"1250.50"`, matching `bestQuoteRequestSchema`), not `Money` objects — deliberately. A quote request is pricing an amount, not yet stating a money fact with a settled scale; every OTHER creating request (`payouts.create`, `payment_links.create`, …) uses `Money`. If you learned the shape from one of those, do not send a `Money` object to `quotes.create` — it is a plain decimal string and a `Money` object there is `400`. ## Idempotency-Key Every mutating request carries an `Idempotency-Key` header: - Caller-generated, 16–128 characters of `[A-Za-z0-9_-]` (a UUID qualifies); the shape is enforced — anything else is `400 invalid_request/idempotency_key_invalid`. - A replay of the same key within 24 hours returns the **stored response**, never a second money movement. - A different request body under the same key is `idempotency_error` (409) — never a silent merge. - The store is keyed `(key, account, route)`, so the same key on two different endpoints is not a collision. ## Swaps-Version Behaviour within `/v1` is pinned by a dated header, `Swaps-Version: 2026-09-04`: - A key carries a default version; a request header overrides it for that call. - Every response echoes the version it was served under — literally: a date older than the oldest version this deployment still serves is never echoed verbatim, it comes back as that oldest version, because that is the behaviour you actually got. - A new date is cut only for a genuine behaviour change — additive fields are never a version bump, and a client must ignore fields it doesn't recognise. - A breaking change gets a new resource path or tool name instead; a published path or tool signature is never repurposed, and old and new overlap for at least six months. ## Swaps-Account A credential that belongs to more than one account (a dashboard session shared across a team) selects which one a request acts on with an optional header, `Swaps-Account: acct_…`; omitted, it acts on the caller's default account. A business key is unaffected — a key already belongs to exactly one account and cannot be redirected by this header — and the parameter is published only on an operation a dashboard session can call: the server does not read it on the API-key path, so declaring it everywhere would offer a scoping control a business key silently ignores (A1-2 fixer round 1). ## Forward compatibility — enum members Two rules, and they apply to different fields: - A **response** field's enum may gain a new member within `/v1`, without a version bump, when the API-CANON marks it growth-prone (event types, lifecycle statuses, a partner's own vocabulary). Treat an unrecognized member as an opaque string — do not throw, do not treat it as `null`. The generated TypeScript SDK types these fields as `"known" | "values" | (string & {})` for exactly this reason: the literal union still gives you autocomplete, and the `string` half keeps a new member from failing to compile or to parse. - A **request** field's enum never grows silently: sending a value this deployment does not recognise is `400 invalid_request`, always — including on `webhook_endpoints.create`/`.update`'s `event_types`, which draws from the very same catalogue `Event.type` publishes as open. Subscribing to a name this deployment cannot yet deliver cannot be honoured, so it is refused rather than silently accepted and never fired. ## Pagination List endpoints take `limit` (default 25, max 100) and an opaque `cursor`, and return: ```json { "data": [ /* … */ ], "has_more": true, "next_cursor": "eyJjcmVhdGVkX2F0Ijoi…" } ``` `next_cursor` is always present — `null` when there is no next page, a cursor string when there is; never omitted. `limit` outside `1..100` is never rejected: `0`, a negative number or anything non-numeric falls back to the default (25); anything above 100 is capped to 100. The cursor encodes `(created_at, id)` or the resource's own monotonic identity column. A cursor list never returns a total count — build a "how many" UI from a resource's own `/summary` endpoint where one exists (for example `/v1/activity/summary`), not by paging to the end. Capability and eligibility reads are unpaginated — they're small, bounded projections by design. ## Errors One envelope for every `/v1` response and every MCP tool failure: ```json { "error": { "type": "invalid_request", "code": "payer_type_invalid", "message": "…", "doc_url": "https://docs.swaps.app/…", "request_id": "req_…" } } ``` `type` is the class an agent needs to pick a recovery; `code` is a stable, additive-only identifier — a code, once shipped, is never renamed or removed. `doc_url` resolves to the [full code list](/errors) (A1-1), anchored per code — every operation's `4xx` responses on the [reference](/reference) also carry a `422`/`409` where its handler can actually return one, and every `available`/`dark-flag` operation declares `500`. Two optional fields carry the specifics: `param` names the offending request field where one can be named, and `details` is machine-readable context for the codes that have some — the values you would otherwise parse out of `message`. Read `details` by `code`, never generically: each code documents its own keys, and a code with nothing to add omits the object. `allowed_rails_invalid_for_currency` carries `currency`, `allowed_rails` and `offerable_rails`. ### The type table | `type` | HTTP | Agent recovery | |---|---|---| | `invalid_request` | 400 / 422 | Fix the argument and retry. | | `authentication_error` | 401 | Re-authorize; do not retry as-is. | | `permission_error` | 403 | Wrong actor or scope — call as the right one. | | `not_found` | 404 | The id is not visible to this caller — also the cross-account answer. | | `conflict` | 409 | State moved — re-read, then decide. | | `idempotency_error` | 409 | Same key, different body. | | `rate_limit_error` | 429 | Back off; `Retry-After` is set. | | `capability_unavailable` | 409 | Do **not** retry — this corridor or product cannot serve it. | | `temporarily_unavailable` | 503 | A kill switch is thrown, or a dependency is out; retry after `Retry-After`. | | `quota_exhausted` | 402 | An allowance is spent — backing off does not restore it; upgrade or buy credits. | | `provider_error` | 502 / 503 | Upstream failed; retry only where stated. | | `api_error` | 500 | Ours — open a support ticket with the request id. | > **capability_unavailable vs temporarily_unavailable** > > This split is load-bearing. `capability_unavailable` means this corridor or product cannot serve the request — waiting does not change the answer. `temporarily_unavailable` means a kill switch is thrown or a dependency is degraded — it can be un-thrown without a deploy, so the honest recovery is "retry later". Never conflate them, and never return a bare 403, a 500 or an empty success where one of these two is the truth. A cross-account id is always `not_found`, never `permission_error` — the alternative would tell an attacker that an id they can't reach nonetheless exists. One code carries one meaning, but the same meaning can be reachable two ways, and then the `type` differs while the code does not. `allowed_rails_invalid_for_currency` is the worked example: `POST /v1/payment_links` and `PATCH /v1/payment_links/{id}` answer `400 invalid_request` when the rails you are writing would leave the link with nothing a payer could pick — the offending value is in your request, so fix it and retry. `POST /v1/payment_links/{id}/activate` answers `409 conflict` for the same defect — there the offending value is the stored draft, so the recovery is to patch the draft (`allowed_rails: null` clears the restriction) and activate again. Match on `code` for what went wrong, on `type` for what to do about it. ## `livemode` Every object carries `livemode: boolean`. For resources with a dedicated `livemode` column it is set once, at creation, from the calling key's mode (`sk_live_…`/`sk_test_…`) and never toggled after the fact; resources with no column yet currently hardcode `true` on every read regardless of caller — see [Test mode](/test-mode) for which is which. ## Naming Every operation id is `.`, and it is the method name in the SDK, so these names do not change within `/v1`: - The last part is a verb: `payouts.fund`, `payouts.beneficiary.set`, `wallet.transactions.list_recent`. - Something under a parent is part of the resource name: `payouts.events.list`, `payroll_runs.items.get`, `screenings.reports.create`, `payouts.receipt.get`. - Writing a single sub-resource under a parent is `.set` (or `.update` for a `PATCH`): `payouts.beneficiary.set`, `customers.compliance_profile.update`. - Five read-only views that the server computes from their parent keep the form `get_`: `get_readiness`, `get_funding_instructions`, `get_setup_guide`, `get_usage`, `get_summary`. Scopes are `.`. Screenings, traces and transaction lookups use `screenings.read` / `screenings.write`. A key created with the old `screening.*` names keeps working until the next `Swaps-Version` date. Quotes are the one exception: they share `orders.read` / `orders.write`, because a quote exists only to become an order. Your own account is `/account`. It is a real singleton, and a session that belongs to several accounts picks one with `Swaps-Account`. Every other resource is addressed by its id. On `/customers/{id}`, the literal `me` is a **deprecated** alias for your customer id. It works until the next `Swaps-Version` date. Use the `cus_…` id returned by `GET /v1/customers` or `POST /v1/customers` instead. UK Faster Payments is always `faster_payments`, never `fps`. ## Status vocabulary Every operation in the [reference](/reference) is tagged with exactly one of three values (`x-swaps-status`) — never a fourth: - **available** — a live backend exists and `/v1` is a thin router in front of it. - **dark-flag** — live, but gated behind a cohort or feature flag. While the operation's resource switch (`api_v1.`) is off, calling it answers `503 temporarily_unavailable` — an availability lever, never an authorization one. With that switch on, a closed product or launch flag — for example `payment_links_crypto_only` when creating or resuming a subscription, or Receive by bank not launched when creating a virtual account — or a cohort the account is outside answers `409 capability_unavailable` from the operation itself. - **proposed** — does not exist yet. Documented for the contract it will carry, never presented as callable, never hidden. Next: the [full error code list](/errors) · [Errors and events](/events-webhooks) · the [error type table lives inline in the reference](/reference) on every operation too. --- ## Document: Changelog One entry per Swaps-Version date. Nothing lands here before its contract test passes. URL: /changelog # Changelog One entry per `Swaps-Version` date. Nothing lands here before its contract test passes (API-CANON §12) — mirrors `docs/api/CHANGELOG.md` in the repository, the source of record. ## Unreleased — align public integration guidance with the enabled resource API The public MCP page, its Markdown companion, localized installation guidance and generated LLM manifests now describe `/v1` as enabled, subject to scopes, account capabilities and resource availability, and point to the live documentation host. Test mode remains limited to the operations listed in [Test mode](https://docs.swaps.app/test-mode); no complete provider sandbox or completed rollout acceptance is claimed. Published npm route tools remain read-only; hosted account tools preserve per-transaction human confirmation and signing requirements. This corrects documentation only: no API, SDK, tool schema or `Swaps-Version` change. ## Unreleased (2026-10-05) — one login, one account: `POST /v1/accounts` answers `409` `POST /v1/accounts` no longer opens a second account under the same login. A login that already owns a live account gets `409 conflict`, code [`account_already_exists`](/errors#account_already_exists), and nothing is created. Every signed-in login is given its account on its first request without `Swaps-Account`, and only the owner of the live account a request acts on gets past the earlier refusals, so today a request that gets past them always gets this `409` and never a new account. The wallet, verification, payment links, bank details and transactions belong to the login rather than to an account, so a second account would promise a separation that does not exist. The reference marks `accounts.create` as not open today. Every earlier refusal still comes first and is unchanged: `403` for a business key or a `member`, `503` for a test-mode session, `400` for a malformed body or an unrecognised `country`. `GET /v1/accounts` and the `Swaps-Account` header are unchanged. Separate accounts under one login, each with its own verification, are planned for a later release. No `Swaps-Version` change. ## Unreleased — explicit acceptance of legal revisions Adds a disabled-by-default registry and server receipt for a published legal revision, with separate publication, acceptance, effective and per-action requirement dates. No legal release, mailing or enforcement date is activated by this change. Selected new live operations can return `409 terms_acceptance_required` (`capability_unavailable`, `details.revision`/`details.action`) or `503 legal_acceptance_unavailable` (`temporarily_unavailable`, `Retry-After`). People accept through an authenticated Swaps screen, with owner authority required for account terms; personal wallet intents require their own receipt. Keys and agents cannot mint receipts. Existing completed idempotent responses, reads, cancellations and recovery retain their existing paths. No `Swaps-Version` change. See [Errors](https://docs.swaps.app/errors#terms-acceptance). ## Unreleased — save a destination the recipient submitted as their default (payroll) `PayrollRecipient` gains `pending_destination`: a destination the recipient saved through their payout link that is not their default yet (masked; `null` when nothing waits). New operation `POST /v1/payroll_recipients/{id}/adopt_pending_destination` (`payroll.write`, `Idempotency-Key`) saves it as the default, bound to the `submitted_at` you showed (`expected_submitted_at`), and returns the recipient plus `updated_run_items` / `skipped_run_items` for their waiting rows in unapproved runs. New error codes: `pending_destination_changed`, `no_pending_destination` (`409`), and `destination_rail_keyless` (`422`) — the last one also replaces the generic `invalid_request` code payroll approve, execute and the recipient's own destination write answered for the same refusal. On `409 pending_destination_changed` the recipient's default is unchanged; in a rare race, rows the call already filled with the destination you confirmed keep it. Additive; no `Swaps-Version` change. ## Unreleased — fund a payout from the Swaps wallet balance `POST /v1/payouts/{id}/funding_instructions` takes an optional body: `source_chain` (chosen at funding, fixed afterwards) and `funding_source: swaps_wallet`, which prepares one unsigned send from the payer's own wallet to exactly the deposit address — the payer signs it with their passkey. New read: `GET /v1/payouts/{id}/wallet_funding_quote`. `GET /v1/capabilities?product=payouts` lists `funding_sources`. Additive; an absent body behaves exactly as before. Off until enabled per account. ## Unreleased — naming freeze - Operation ids follow one rule, `.`, and something under a parent is part of the resource name. For example, `payouts.list_events` is now `payouts.events.list`, and `screenings.create_report` is now `screenings.reports.create`. Paths are unchanged. See [Conventions](/conventions#naming). - The screening scopes are now `screenings.read` / `screenings.write`. A key created with the old `screening.*` names keeps working until the next `Swaps-Version` date. - The `me` alias on `/customers/{id}` is deprecated. Use your `cus_…` id instead. - Payouts and capabilities now return UK Faster Payments as `faster_payments` instead of `fps`. ## Unreleased — hosted MCP and the OpenAPI contract withdraw the advertised OAuth authorization server Swaps operates no public OAuth authorization server. `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource` answer `404`; the OpenAPI contract's `agentOAuth` scheme and every MCP discovery document that named OAuth 2.1 + PKCE are corrected to advertise a business key (`sk_live_…`/`sk_test_…`) as the sole agent/MCP credential. See [Authentication](/authentication). No credential class that worked before this entry stops working — this corrects what was advertised, not what was enforced. ## Unreleased — truthful quote projection - `rate` (the winner, every `offers[]` entry, every `providers[].offer` echo, and `route_facts.best_price.rate`) is now derived server-side from each offer's own `final_in` ÷ `final_out` decimal values, computed **before** either is rounded into the published `Money` fields — always `from_asset` per `to_asset`, identically for every provider on both `buy` and `sell` — never a provider's own field, whose basis previously differed by provider and by side. Because the derivation runs pre-rounding, `rate` × the published `final_out` amount can differ from the published `final_in` amount in the last digit(s) when an asset's published decimals are coarser than the server's internal precision — treat that as approximate, not an exact identity. Treat `rate` as informational only, never for ranking: compare by `final_out` for a `from_amount` request, or by `final_in` for a `to_amount` request — but only among offers whose `final_out` matches the amount you requested; not every provider honors an exact-output target, so a differing `final_out` means that offer bought a different amount and its `final_in` is not comparable this way. A non-winning offer whose facts cannot produce a usable rate is omitted rather than failing the whole response. - Transak quote execution requires an explicit crypto network. When no usable offers remain for this reason, `409 capability_unavailable` preserves `QUOTE_NETWORK_REQUIRED` and names the missing directional network field. - Winner and per-provider offers add optional `payment_method`, preserving the actual method with its `offer_id`. Unknown methods remain absent; inconsistent methods for the same winning offer are rejected. This additive field does not change `Swaps-Version` or grant execution eligibility. - `POST /v1/quotes` exposes the winning `offer_id` and safe terminal provider outcomes, preserving success across payment methods. Route facts are projected from existing evidence. - Readiness and status come from validated provider expiry; malformed upstream facts return an explicit unavailable response. Public cache hits retain their original fetch time as indicative prices. - Authentication remains required on `/v1/quotes`. Test callers are refused before provider dispatch until sandbox routing is implemented. Order creation remains proposed. - `GET /v1/capabilities?product=buy_sell` now publishes fresh-snapshot defaults (`primary_method`, optional `resolved_limits`) and a bounded coverage grid carrying `fiat` and `direction`; it never creates a quote or starts provider work. A stale or cold route snapshot returns `503 temporarily_unavailable`. ## 2026-09-05 — K1: the `/v1` gateway skeleton - New edge function `supabase/functions/api-v1` behind a global kill switch (`feature_flags.api_v1`, **off** by default) — this release changes no live behaviour until an operator enables it. - First two live operations: `GET /v1/account`, `PATCH /v1/account`, plus the new `GET /v1/accounts` and `POST /v1/accounts` (one login, many accounts). `account.get` / `account.update` flip from `proposed` to `available`; `accounts.list` / `accounts.create` are new and `available` from the start. - New tables `accounts`, `account_members` (backfilled 1:1 from every existing account) and `api_idempotency_keys` (the `(key, account, route)` idempotency store, 24-hour TTL sweep). - Three route-resolution gates land: fail-closed global and per-resource kill switches, business-key and bearer auth classes with fail-closed scopes, and per-class rate limiting on the distributed limiter. - `Account.id` now carries the `acct_` prefix (see [Conventions](/conventions)). ## 2026-09-04 — Contract v1 (gate [A1]) > **Corrected 2026-09-16** > > The "OAuth 2.1 + PKCE with CIMD" auth class this entry announced was never built and is now withdrawn (decision C4-D32) — see [Authentication & keys](/authentication) and the entry above. Two live credential classes remain: business key and dashboard session, plus the public capability token. - Resource-shaped `/v1` on `api.swaps.app`: payment links, clients, products, payments, subscriptions, payouts, payroll, wallet, quotes, orders, capabilities, eligibility, customers, account, activity, events, webhook endpoints, address book, screenings, traces, credits, API keys. - ~~Three auth classes (business key, OAuth 2.1 + PKCE with CIMD, public capability token); the dashboard is a first-party OAuth client.~~ Superseded above — see the 2026-09-16 correction. - `Money` object, `Idempotency-Key`, cursor pagination, one error envelope, `Swaps-Version` date header, `livemode` on every object, test mode by key prefix. - The status of every operation is stated in the reference (`available` · `dark-flag` · `proposed`); non-available operations are documented with their reason and are not callable. - Business-key scopes fail closed: a key with `scopes` null or empty now denies every action instead of granting all of them. - Usage-log KPI columns and distributed rate limiting land on the usage-tracking table, feeding the [platform KPIs](https://github.com/swapsapp/swaps/blob/main/docs/api/KPI.md). Next: [Get started](/get-started) to build against the current contract · [Providers & coverage](/providers-coverage) for what's live right now, rail by rail. --- ## Document: Authentication & keys Two live credential classes — business keys and the dashboard session — plus public capability tokens. A third, agent OAuth, is withdrawn. URL: /authentication # Authentication & keys Two live credential classes, structurally separated (API-CANON §8, RESOURCE-MODEL §0.9) — a request authenticates as exactly one of them — plus public capability tokens, which are not an identity at all. > **Corrected 2026-09-16** > > This page used to describe a third class, agent OAuth 2.1 + PKCE, as live. It was not: the published `authorization_endpoint` resolved to the marketing SPA's catch-all route, not a working authorize screen. Swaps withdrew the claim rather than build the server (decision C4-D32) — see [the changelog](/changelog). Hosted MCP and every agent integration authenticate with a business key today. | Class | Credential | Acts as | Reaches | |---|---|---|---| | Business key | `x-api-key: sk_live_…` / `sk_test_…` | the account that owns the key | its own account's resources | | Public token | a capability token embedded in a payer-facing URL | nobody — no identity is resolved | exactly one object's payer projection | The dashboard session (a GoTrue session token, first-party only) reaches a small additional set of operations — account self-service and API-key lifecycle — that a business key must never reach; see below. ## Business keys `x-api-key` is hashed to SHA-256 and checked against status, expiry, IP allowlist, plan and scopes on every request. A key never mints another key — issuance, rotation and revocation are dashboard-only. - **Test vs live** is the key prefix, not a header or a query parameter. `sk_live_…` routes to production; `sk_test_…` acts as the account's test twin and reaches only the operations [Test mode](/test-mode) lists — API-key, request-log and account reads run in production; webhook endpoints, webhook deliveries and the per-object event lists run in the development project; and every other operation a key may call answers `503 temporarily_unavailable`. - **Scopes** are `.`, declared on the key and enforced per action — an empty or missing scope list denies every action rather than granting one (fail-closed, K2a). - **IP allowlist** is optional per key; an empty allowlist means no restriction, a populated one refuses every other source IP. - Keys are shown once at creation. Support cannot read a lost key back to you — roll it from Developers → API keys. This is the one credential class an agent, an MCP client, or any external integration authenticates with. There is no separate agent-only flow. ## Scopes `POST /v1/api_keys` requires a non-empty `scopes` array — a key exists with its scope list already set, before it makes a single call. The shape (`.`) is uniform; the mapping from a route's URL to the scope it checks is not. Five scope families gate routes whose path doesn't share their name, so guessing a scope from the resource name gets a key its owner believed was complete a `403 permission_error` on the call that actually needed it. This table is derived from `x-swaps-scope` in the [reference](/reference) — one value per operation, source `docs/api/openapi/src/paths/**` — so it can't drift from what the router enforces. | Scope | Unlocks | Resource flag | Business key reaches | |---|---|---|---| | `account.read` | Account profile, readiness, setup guide | `api_v1.account` | all | | `account.write` | Update the account profile and create accounts (dashboard session only), and join the webhook and card waitlists (`POST /v1/webhook_waitlist`, `POST /v1/card_waitlist`) | `api_v1.account`, `api_v1.webhooks`, `api_v1.tools` | waitlists only | | `capabilities.read` | Funding-method and currency capabilities | `api_v1.capabilities` | all | | `customers.read` | Read customers | `api_v1.customers` | all | | `customers.write` | Create/update customers — a dashboard session must be the owner (compliance profile: owner/admin); a business key reaches all three, via `kyc_links` | `api_v1.customers` | all | | `orders.read` | Read orders — **and** `GET /v1/quotes/{id}` | `api_v1.orders`, `api_v1.quotes` | all | | `orders.write` | Create/cancel orders — **and** `POST /v1/quotes` | `api_v1.orders`, `api_v1.quotes` | all | | `payment_links.read` | Links, clients, products, payments — **and** subscriptions and their invoices | `api_v1.payment_links`, `api_v1.subscriptions` | all | | `payment_links.write` | Create/update links, clients, products — **and** create/pause/resume/cancel subscriptions | `api_v1.payment_links`, `api_v1.subscriptions` | all | | `payouts.read` | Read payouts | `api_v1.payouts` | all | | `payouts.write` | Create/cancel payouts | `api_v1.payouts` | all | | `payroll.read` | Read payroll runs, recipients, items | `api_v1.payroll` | all | | `payroll.write` | Create, approve, fund payroll runs | `api_v1.payroll` | all | | `wallet.read` | Balances, addresses, deposits, virtual accounts | `api_v1.wallet` | partial — `wallets`/`balances`/`transactions`/`deposit_routes`/`send_routes`/`conversion_pairs` reads only; deposit/send/offramp intents, virtual accounts and external accounts are dashboard session only | | `wallet.write` | Send, convert, manage virtual-account lifecycle | `api_v1.wallet` | none (dashboard session only) | | `events.read` | Read/stream account events | `api_v1.events` | all | | `activity.read` | Read the account's activity log | `api_v1.activity` | all | | `developers.read` | API keys, request logs — **and** webhook endpoints/deliveries | `api_v1.developers`, `api_v1.webhooks` | all | | `developers.write` | Create/rotate/revoke API keys — **and** create/update/delete webhook endpoints, rotate their secret, send test events, replay deliveries | `api_v1.developers`, `api_v1.webhooks` | webhook endpoints/deliveries only | | `screenings.read` | Screenings, screening reports, traces, chain-transaction lookups. A key created with the old name `screening.read` keeps working until the next `Swaps-Version` date | `api_v1.tools` | all | | `screenings.write` | Create screenings, screening reports (spends a credit), traces. A key created with the old name `screening.write` keeps working until the next `Swaps-Version` date | `api_v1.tools` | all | | `address_book.read` | Read saved addresses | `api_v1.address_book` | none (dashboard session only) | | `address_book.write` | Create/update/delete/recheck saved addresses | `api_v1.address_book` | none (dashboard session only) | | `credits.read` | The caller's own credit balance and credit events | `api_v1.tools` | none (dashboard session only) | | `credits.write` | Buy a credit pack | `api_v1.tools` | none (dashboard session only) | Five families don't share their route's name: `quotes` needs `orders.*`, not a `quotes` scope; every `webhook_endpoints`/`webhook_deliveries` route needs `developers.*`, not `webhooks.*`; every `subscriptions` route needs `payment_links.*`; every `traces` and `transactions/{chain}/{hash}` route needs `screenings.*`, like `screenings` itself, and sits behind the same `api_v1.tools` resource flag that also gates `credits.*` and `POST /v1/card_waitlist`; and `webhook_waitlist`/`card_waitlist` need `account.write`, not `developers.*`/`credits.*`. The **Business key reaches** column is a second, independent gate: `allowedCallers` on the route, not the scope. A scope only appears on a key at all, so a route a business key can never reach (`dashboard session only`/`partial`/`waitlists only`/`webhook endpoints/deliveries only` above) still denies every key holder regardless of its scopes — see the necessary-but-not- sufficient rule below. Two rules govern every check above: - **Fail closed.** An empty or missing scope list denies every action — no scope is ever granted by omission. - **Necessary, not sufficient.** A scope only gets a request past the scope check. A route may also be bearer-only (a business key can never reach it) or owner/admin-only (a dashboard session must hold that `account_members` role) — the [reference](/reference) states the caller classes an operation accepts, per operation, via `x-swaps-caller`. `agent` appearing in that list does **not** by itself mean a business key can reach the route — only the literal value `business_key` does; use the table's **Business key reaches** column above, or check for `business_key` in `x-swaps-caller` directly, never `agent`. ## Agents and MCP clients — business key only Agents and MCP clients (including the hosted MCP server at `mcp.agent.swaps.app`) authenticate with a business key as a `Bearer` token, exactly as described above. **Swaps operates no public OAuth authorization server** — `/.well-known/oauth-authorization-server` and `/.well-known/oauth-protected-resource` both answer `404`, deliberately: publishing authorization-server metadata for a server that did not exist was worse than publishing none, since it told a compliant client a flow was safe to complete when it was not. Building a real authorization server (client registration, a working `/authorize` UI, a matching issuer) remains a deferred, undecided product question — this page will be corrected again if that changes. The hosted MCP server's tool catalogue (`tools/list`/`server/discover`) tags every scoped tool with the `http`/`bearer` credential class it actually checks, never `oauth2`. ## Public capability tokens A public token is embedded in the path of a payer-facing resource — `/v1/payment_sessions/{token}`, `/v1/payroll_recipient_sessions/{token}` — and resolves exactly one object's payer projection, never an identity. These routes are dispatched **before** any auth resolution: a business key is structurally unable to reach them, and a token is structurally unable to reach anything else. Nothing sensitive — bank details, settlement snapshots, internal ids — crosses onto a payer projection; it is an allowlist, not a filtered merchant view. > **Never a fourth class** > > Every operation in the [reference](/reference) states its caller classes explicitly (`x-swaps-caller`). If a surface you need isn't reachable by any of the classes above, it is out of scope for that credential — not a bug to route around. ## Rotating and revoking - **Roll** a business key from Developers → API keys — the old key keeps working for a short overlap window, then stops. - **Revoke** immediately invalidates a key; any in-flight request using it fails with `authentication_error`. - A dashboard session is revoked with `POST /v1/account/sessions/revoke_all`. Next: [Test mode](/test-mode) · [Conventions](/conventions#errors) for what an expired or wrong credential returns. --- ## Document: Agents & MCP One hosted server, stateless HTTP — call a tool instead of hand-rolling a REST request. Authenticates with a business key; Swaps operates no public OAuth authorization server. URL: /agents-mcp # Agents & MCP The hosted server is at `https://mcp.agent.swaps.app/mcp` over Streamable HTTP. > **Availability** > > The account tools documented below call the `/v1` resource API, enabled in production. > Access still depends on scopes, account capabilities and resource availability: a tool whose resource is not switched on answers `503 temporarily_unavailable`. A > successful MCP connection or tool listing does not establish account access or operational > availability — call `list_capabilities` with a live key first. The [public MCP > page](https://www.swaps.app/developers/skills/mcp-server) lists the published surfaces. ## Choose a surface | Surface | Authorization | Scope | | ----------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Published `@agent.swaps/mcp-server@1.1.0` | No business key | Local stdio: five legacy read-only route tools and `legal://` resources. Shipped independently; not a proxy for hosted MCP. | | `https://mcp.agent.swaps.app/mcp` | Business key as Bearer for scoped calls | Legacy discovery and Search tools, plus generated account tools whose calls depend on `/v1`. Listing a tool is not permission to execute. | | `https://mcp.agent.swaps.app/mcp-public` | No business key for the public tool set | Six read-only tools: wallet inspection, Search preview, report retrieval/rendering, taxonomy and corridors. No quotes, checkout, report creation or money operations. | | `https://api.swaps.app/v1` | Business key, scopes and account capabilities | Resource API, enabled in production; a paused resource answers `503 temporarily_unavailable`. | Swaps operates no public OAuth authorization server. Obtain an existing business key from the account holder through Developers → API keys; automatic key issuance is not promised. A `sk_test_` key reaches only what [Test mode](/test-mode) lists — API-key, request-log and account reads, and webhook endpoints, webhook deliveries and the per-object event lists in the development project — so every account tool below needs a live key: each one's operation answers a test key `503 temporarily_unavailable`. Connect with the business key and call `list_capabilities` with a live key first. Describe only the actions that response permits. When a call answers `503 temporarily_unavailable`, report the availability error without attempting a money operation. ## What a tool can never do The server's own system prompt states this in plain terms, and every tool description repeats the parts that apply to it: - Tools read state, price routes and **prepare** money movement — none of them sign, send, refund or declare something settled. - Every money step still needs the person's own confirmation per transaction — never chain a quote straight into funding, or a prepared withdrawal into a send. - Bank details, identity documents and attestations are never tool arguments; a tool hands back a link the person opens themselves. - Money truth comes from the normalized state a tool returns — a payer's "I've sent it" is a self-report, not settlement. - A degraded flag or an error means _unknown_ — never zero, never "nothing happened". - Quotes expire; re-quote rather than reuse. Idempotency keys are yours to generate and safe to reuse on a retry. Structurally excluded — no tool exists for these, on purpose: payout beneficiary and wallet external-account creation, payment-link activation, payroll execution and funding, sends that broadcast, and any refund. Each is a REST-only, human-attested step. ## New account contract The statuses below describe implementation, not production availability. The full generated list lives in `docs/api/MCP-v1.md` §4 and regenerates with the spec; this is a representative slice, one row per product: | Tool | Status | What it does | | --------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `list_capabilities` | implemented | What this account can actually execute right now, per product. | | `create_payment_request` | implemented | Draft a payment request the merchant reviews — cannot activate, cannot charge a card. | | `check_payment_request` | implemented | One request's state, attempts and timeline in a single call. | | `prepare_invoice_payout` | implemented | Create a payout for an external invoice and return a draft ready to fund — beneficiary bank details are REST-only, excluded from MCP. | | `prepare_payroll_run` | implemented | Draft a pay run for review — nothing is funded or paid; recipients add their own destination afterwards. | | `get_wallet_balance` | implemented | The signed-in person's Tempo balance, every stablecoin plus a server-computed total. | | `prepare_wallet_withdrawal` | dark-flag | Prepare a cross-chain withdrawal and return the steps the person's own passkey must sign — nothing leaves the wallet until they sign. | | `get_quote` | implemented | Price a buy or sell across every connected provider; quotes expire. | | `check_address_risk` | implemented | Screen a blockchain address before funds are sent to it — a level, a score, named flags. | | `trace_address_funds` | implemented | Follow where value moved from one address, after a risk check flags it — slow and expensive, called deliberately. | | `get_verification_status` | dark-flag | The account's verification state and the single next step — status only, never documents. | | `create_subscription` | dark-flag | Draft a version-1 crypto subscription — a schedule only, no money moves until each invoice is paid. | ## Resources and prompts - **Resources** — `legal://swaps/api-terms` (live today), `swaps://capabilities` (the caller's own capability projection), `swaps://docs/` (this site's `.md` twin of every page — see below). - **Prompts** — `onboard` (what this account can do today and the next unblocking step) and `pay_an_invoice` (the guarded flow: capabilities → draft → hosted beneficiary → funding instructions → confirmation). ## Errors A failed tool call returns the same REST error envelope (`type`, `code`, `message`, `doc_url`, `request_id`) as structured content with `isError: true` — see [Conventions → Errors](/conventions#errors). There is no separate MCP-only error dialect. ## Connecting a specific client ```shell title="Claude Code" claude mcp add --scope project --transport http swaps https://mcp.agent.swaps.app/mcp ``` ```json title="Claude Code (.mcp.json)" { "mcpServers": { "swaps": { "type": "http", "url": "https://mcp.agent.swaps.app/mcp", "headers": { "Authorization": "Bearer ${SWAPS_API_KEY}" } } } } ``` ```json title="Cursor (~/.cursor/mcp.json)" { "mcpServers": { "swaps": { "url": "https://mcp.agent.swaps.app/mcp", "headers": { "Authorization": "Bearer ${env:SWAPS_API_KEY}" } } } } ``` ```shell title="Codex" codex mcp add swaps --url https://mcp.agent.swaps.app/mcp --bearer-token-env-var SWAPS_API_KEY ``` Supply `SWAPS_API_KEY` in the client environment. Do not store the key value in shared configuration. Verify `initialize` and `tools/list`; call `list_capabilities` with a live key before any account action. An authorization or availability error is not an empty capability list. **Ask your agent** Use an existing authorized live business key. > Connect to the Swaps MCP server. Call list_capabilities, then summarize in plain English what this account can and cannot do today, product by product. ## Read it as text Every page on this site ships an agent-readable `.md` twin, generated at build time (`docs.publishMarkdown` — see [`llms.txt`](/llms.txt) and [`llms-full.txt`](/llms-full.txt)). The "Copy page" control in the page header copies that page's own Markdown body — the same text an agent fetches. Next: [Providers & coverage](/providers-coverage) · back to [Get started](/get-started). --- ## Document: Wallet A non-custodial Tempo balance — deposits, sends and conversions. Nothing here can sign; the holder's own passkey does. URL: /products/wallet # Wallet **Status:** Dark-flag — live behind a cohort or flag A non-custodial Tempo balance — deposits, sends and conversions. Nothing on this surface can sign; the holder's own passkey is the only signer, on their own device. > **Mixed status by resource** > > Reads (`wallets`, `balances`, `transactions`) are `available` today. Everything that prepares a money movement (`deposit_intents`, `send_intents`, `offramp_intents`, `conversions`) is `dark-flag` — live behind a cohort, answering `capability_unavailable` until it opens for a given account. Read the [reference](/reference) for each operation's own status rather than assuming from this page. ## Concept The wallet is the holder's own Tempo balance. The API can read it, price a deposit or a send, and build the unsigned steps for one. It holds no wallet key, so it cannot sign a send for you, and it cannot recover the wallet if no usable passkey or synced copy remains. Provider and token restrictions may still apply. ## Resources | Object | What it is | |---|---| | `wallets` / `balances` / `transactions` | The holder's address, per-stablecoin balances plus a computed USD total, and a bounded recent-activity window. | | `virtual_accounts` | Bank details the holder can receive a transfer into. What arrives is delivered to the wallet as stablecoin. See [Receive by bank](#receive-by-bank). | | `deposit_intents` | A one-time, amount-bound cross-chain deposit address — single-use, expires. | | `send_intents` | A prepared cross-chain withdrawal — the ordered steps the holder's passkey signs, quoted net of fees. | | `offramp_intents` / `external_accounts` | A prepared payout to the holder's **own** verified bank account — never a third party's, on anyone's behalf. | | `conversions` | An unsigned on-chain swap transaction, built but never sent by the API. | ## Minimal flow (a cross-chain send) 1. `GET /v1/wallet/balances` — or the MCP tool `get_wallet_balance`. 2. `POST /v1/wallet/send_intents` — prepare the withdrawal (`prepare_wallet_withdrawal`); nothing leaves the wallet yet. 3. The holder reviews the quoted amount and signs the returned steps with their own passkey. 4. `POST /v1/wallet/send_intents/{id}/source_tx` — record the broadcast hash (`confirm_wallet_withdrawal`) so settlement can be tracked. 5. `GET /v1/wallet/send_intents/{id}` — or `get_transfer_status` — to watch it settle. ## Receive by bank A virtual account gives the holder bank details to receive a transfer into. `GET /v1/wallet/virtual_accounts` lists them and `POST /v1/wallet/virtual_accounts` creates one. Two fields say what kind of account a row is: - **`destination.rail`** is the bank rail: `ach`, `sepa`, `spei`, `faster_payments`, `pix`, `wire` or `bre_b`. It has one spelling per rail: an account the provider sync recorded as `ach_push` is published as `ach`, as an account created through `/v1` always was. It is an open enum, so a client tolerates a rail it does not know, and a stored rail outside the list is published as stored. - **`provider_environment`** is `sandbox`, `production` or `null`. `sandbox` means a provider test account: its bank details are test data and no real bank transfer arrives. `null` means no environment was recorded and is never evidence that an account is live. Only `sandbox` marks a test account. Swaps does not work it out from the host or from `livemode`, which stays the caller's own mode. Not every currency can be opened. When a Payment links collection account already receives a currency into the same wallet, `GET /v1/capabilities?product=wallet_bank` reads that corridor `not_enabled` with `blocked_reason: collection_account_uses_currency` and `action: none`, and `POST /v1/wallet/virtual_accounts` for it answers `409 conflict`. Read the capability before offering the account. See [Providers and coverage](/providers-coverage). ## Where a transfer came from Each transfer in `GET /v1/wallet/transactions` (and `GET /v1/wallet/transactions/{hash}`, or the MCP tool `list_wallet_activity`) carries `origin`. On the list every row has it: `null`, or an object. | `origin` | Meaning | |---|---| | `{ "kind": "bank_deposit", "virtual_account_id": "va_…", "currency", "rail" }` | An incoming transfer that one of the caller's own wallet virtual accounts recorded as the delivery of a bank deposit. `currency` and `rail` are that account's `destination.currency` and `destination.rail`, spelled the same. The deposit is still one transfer row. | | `null` | Any other transfer, and any transfer Swaps cannot tie to one account: a hash claimed by two accounts, an account you may not read, or an events lookup that failed. `null` is not proof that a transfer is not a bank deposit. | Only an owner or admin who may read the account sees `bank_deposit`; a business key or another member reads `null`. It is never inferred from the sender, the amount or the timing, and it never carries a provider id or a bank account number. `kind` and `rail` are open enums; tolerate a value you do not know. On the single read, a missing `origin` means the same as `null`. > **Degraded means unknown, never zero** > > A rate-limited chain read returns `degraded: true` with an empty list — never read that as "no activity" or a zero balance. History here is a bounded recent window, not a full ledger. ## What an agent can and cannot do here Every wallet-write tool *prepares* — `prepare_wallet_withdrawal`, `create_deposit_address`, `prepare_bank_withdrawal` — and returns unsigned steps or a deposit address; none of them broadcasts anything. Adding a bank destination is own-account only and re-verified against the provider at spend time — an agent can never add one on someone else's behalf. Next: the [reference](/reference) for the full intent schemas · [Pay an invoice](/products/pay-invoice) for the fiat-out counterpart that doesn't touch the wallet. --- ## Document: Payroll Draft a pay run; recipients add their own destination afterwards. Nothing is funded or paid until you say so, explicitly, twice. URL: /products/payroll # Payroll **Status:** Available Draft a pay run; recipients add their own destination afterwards. Nothing is funded or paid until you say so, explicitly, twice. ## Concept A pay run holds a list of items — one per recipient, each with an amount. Recipients you haven't paid before get their own hosted link to add a bank account or wallet address; you never collect or transmit their destination on their behalf. ## Resources | Object | What it is | |---|---| | `payroll_runs` | The run itself — status, funding state, currency, recipient summary. | | `payroll_run_items` | One line per recipient — destination status, amount, per-item status. | | `payroll_recipients` | People you've paid before. The one write: save a destination the recipient submitted as their default. | | `payroll_templates` | A saved item list from a previous run, for building the next one faster. | | `payroll_recipient_sessions` | The recipient's own hosted page (public token) to add or confirm their destination. | ## Lifecycle `reviewed → funding_pending → funded → executing → completed`, with `partial`, `failed` and `cancelled` as terminal branches. `funded` never means *fully* funded — the funding classifier is a boolean gate, not a percentage. ## Minimal flow 1. `POST /v1/payroll_runs` — draft it with recipients and amounts. 2. `GET /v1/payroll_runs/{id}/readiness` — a preflight over the payer-compliance gate `execute` checks first (Bridge verification, KYC, ToS, rail endorsement), without executing anything. A `ready: true` answer does not by itself guarantee `execute` succeeds — funding, destinations and rail resolution are checked separately, at the mutation. 3. `POST .../approve`, then fund it (bank or crypto funding instructions). 4. `POST .../execute` — **REST-only, the money boundary.** This is the one step no MCP tool performs. 5. `GET /v1/payroll_runs/{id}` or the MCP tool `get_payroll_run` to watch it complete. > **Caps, always checked twice** > > $25,000 per run, $10,000 per item, 500 rows per run — enforced at both create **and** execute, so a run that was valid when drafted can still be refused at execute if something about the account changed in between. ## When a recipient saves a destination A destination the recipient saves through their own link pays that run's row right away. It does not become their default on its own: it waits on the recipient as `pending_destination` (masked — asset, network or rail, country, last four), and new runs keep asking until you save it. 1. `GET /v1/payroll_recipients` — a recipient with a non-null `pending_destination` has answered. Show the masked destination. 2. `POST /v1/payroll_recipients/{id}/adopt_pending_destination` with `{"expected_submitted_at": ""}`. It saves the destination as the default and fills this recipient's rows that still wait for a destination in runs not yet approved — `updated_run_items` lists them. A row the destination cannot pay (another currency) keeps asking the recipient and is listed in `skipped_run_items`. 3. `409 pending_destination_changed` — the recipient saved another destination after you read it. Their default is unchanged: read the recipient again and show the new one before you save it. In a rare race — the recipient saves a new destination while your call runs — rows your call already filled with the destination you confirmed keep it; the next read of those runs shows them. Moves no money. A Swaps Wallet address on a network other than Tempo, which nothing can sign for, is refused `422 destination_rail_keyless` before anything is written. There is no MCP tool for this step: it changes where future payouts go, so a person confirms it. ## What an agent can and cannot do here `prepare_payroll_run` drafts a run for review — it never collects bank or wallet details itself, and it never funds or executes anything. `execute` has no MCP tool; running payroll is deliberately a REST-only, human-confirmed action, the same boundary [payment link activation](/products/payment-links) draws. Next: the [reference](/reference) for the full run and item schemas · [Conventions](/conventions) for how the fee (1%, employer-funded on top) shows up on the wire. --- ## Document: Payment links Request money with a hosted link the payer opens themselves — no terminal, no manual reconciliation. URL: /products/payment-links # Payment links **Status:** Available Request money with a hosted link the payer opens themselves — no terminal, no manual reconciliation. ## Concept A payment link is a request for money that lives as a `draft` until you activate it. Once active, the payer opens a hosted page and pays by bank or by crypto — whichever rails you allowed. ## Objects | Object | What it is | |---|---| | `payment_links` | The request itself — status, amount, allowed rails, the merchant's own view. | | `clients` | Who you're billing, saved so future links can be addressed to them. | | `products` | A reusable line item — name and unit price — for links built from a catalogue. | | `payments` | One attempt to pay a link. Only the payer creates one; there is no refund operation. | | `payment_sessions` | The payer's own projection at the public link — never the merchant field names verbatim. | ## Lifecycle The statuses and edges below are the ones a link can take. A link mostly moves forward, with two backward edges: - A payment attempt that fails before any money arrives sends the link from `processing` back to `viewed`, once, so the payer can try again. - A deposit sent back on a collection account moves a `paid` link back to `processing`. The link is payable again, and its events record `funds_returned` with `reopened: true`. `refunded` means the payer's money came back to them after it reached the provider — for example a bank transfer returned for a payee-name mismatch. Swaps has no refund operation: you cannot start one, and this status is set only by the provider's return. The event is `payment_link.refunded` (recorded as `funds_returned` in the link's own events). A `funds_returned` event does not always mean `refunded`: on a returned collection-account deposit the link stays payable, as above. You see "Refunded"; the payer sees "Returned" — one stored value, two audience labels. A `settled` link is final and never moves to `refunded`. **Lifecycle** Steps: draft → active → viewed → processing → paid → settled - **active, viewed or processing → expired** — expires_at passes before the payer finishes. - **draft, active, viewed or processing → cancelled** — you cancel; discarding a draft is the same call. While processing, open attempts that are still waiting for funds are cancelled at the provider first. If the provider refuses, the call fails and the link stays as it was. - **processing → viewed** — an attempt fails before any funds arrive; the payer can pick again (once). - **processing or paid → refunded** — the provider returns the payer's funds; there is no merchant refund operation. - **paid → processing** — a deposit is returned on a collection account; the link is payable again. ```mermaid stateDiagram-v2 [*] --> draft draft --> active : activate draft --> cancelled : cancel active --> viewed : payer session view active --> expired : expires_at passes active --> cancelled : cancel viewed --> processing : payer starts a payment viewed --> expired : expires_at passes viewed --> cancelled : cancel processing --> paid : funds confirmed processing --> viewed : attempt failed, no funds received (once) processing --> refunded : provider returned the funds processing --> expired : expires_at passes processing --> cancelled : cancel paid --> settled : provider settlement lands paid --> refunded : provider returned the funds paid --> processing : deposit returned on a collection account; the link is payable again expired --> [*] cancelled --> [*] refunded --> [*] settled --> [*] ``` `cancel` on a `paid` or `settled` link is refused with `409 conflict`. On an `expired`, `refunded` or already-`cancelled` link it is a no-op: `200` with the link unchanged. Read `status` from the response. `cancel` also answers `409 conflict`, and changes nothing, while a payment of the link is being settled. That includes a `crypto_relay` payment whose funds Relay has already received: that money is not closed out from under the bridge. Once Relay refunded them and the refund transaction is recorded, that payment no longer blocks the cancel. Cancelling a link whose `crypto_tempo` payment arrived short is allowed; that payment becomes `unmatched` with `unmatched_reason: partial_before_cancel` (see [When money arrives and no payment matches](#when-money-arrives-and-no-payment-matches)). ## Minimal flow Activation needs to know where the money lands. Draft the link with one of these two, or `activate` refuses it: - **A saved destination**: `settlement_destination: { "address_book_id": "adr_…" }`. It points to a bank account or crypto address you saved in `/v1/address_book`. That resource takes a dashboard session or an agent key, never a business key. Save the destination once, under Addresses in the dashboard or with `POST /v1/address_book` (below). No dashboard screen shows its `adr_…` id today: read it from `GET /v1/address_book` with an agent key or a dashboard session (the `id` field, `adr_` plus a UUID, for example `adr_3f1c2e4a-8b6d-4f2a-9c1e-5d7b3a9e0f21`), then reuse it from your key. Without one, use the crypto-only path. You never send the raw address or bank details here. - **Your Swaps Wallet, crypto only**: `accepted_rail_kinds: ["crypto"]`, **USD only**. It is open only where the crypto-only capability is enabled for your account; otherwise create answers `409 capability_unavailable`. Don't send both on the same create (`400 settlement_conflicts_with_destination`). To settle to a bank account, save it first. Every bank shape (SWIFT excepted for settlement: `activate` answers `422 settlement_rail_unsupported`) requires `account_holder`, the account owner's legal name as the bank holds it; the link settles under that name. An IBAN entry, with a dashboard session or an agent key: ```bash curl https://api.swaps.app/v1/address_book \ -X POST \ -H "Authorization: Bearer $SWAPS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "rail": "iban", "label": "Business EUR account", "account_holder": "Example Trading OU", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX" }' ``` The answer is `201` with the entry's `id` (`adr_…`), bank details masked. A link that settles to a bank account can also be paid in USDC (`crypto_bridge`), converted to your bank's currency, where that rail is enabled. 1. `POST /v1/payment_links` with `amount` and `settlement_destination` (or `accepted_rail_kinds: ["crypto"]` on a USD link). See [Get started](/get-started) for a full request and response. The response is a `draft` with `url: null`. 2. `POST /v1/payment_links/{id}/activate` with `{ "attestation_accepted": true }` and an `Idempotency-Key`. This is REST-only: a human compliance step, excluded from MCP. On success `status` is `active`, and `url`, `payable_rails` and `payable_rail_kinds` are set. A refused activation leaves the link a `draft`: | Answer | Meaning | Fix | |---|---|---| | `400 settlement_destination_required` | An ordinary (not crypto-only) draft has no `settlement_destination`. | `PATCH` it with `settlement_destination: { "address_book_id": "adr_…" }` and activate again. | | `409 allowed_rails_invalid_for_currency` | The stored `allowed_rails` leaves the link with no rail a payer could use. `error.details.offerable_rails` lists the ones that would work. | `PATCH` with `allowed_rails: null` or a rail from that list, or change the currency. | | `422 crypto_only_currency_not_usd` | A crypto-only (Swaps Wallet) draft is not in USD. That rail pays out in USD stablecoins with no FX step. | Draft it in USD, or use a saved destination instead. | | `422 settlement_rail_unsupported` / `settlement_account_holder_missing` / `settlement_details_incomplete` | The saved destination can't receive this link's funds, or is missing a field the provider needs. | Complete that address-book entry or choose another one. | | `422 tempo_settlement_currency_not_usd` | The saved destination is on the Tempo network and the link is not in USD. | Same fix as `crypto_only_currency_not_usd`. | | `422 no_payable_rail` | The only rail a payer could use is `crypto_relay` above its per-invoice cap, so every payer would be refused `amount_above_rail_maximum`. `error.details` carries `rail` (`crypto_relay`), `reason` (`amount_above_rail_maximum`) and `max_amount`, the same `Money` the `crypto_relay` corridor publishes. The link stays a `draft`. | Lower the amount, or let another rail pay it with `PATCH` and `allowed_rails`. A link that any other rail can pay activates as before. | | `400 invalid_request` | `attestation_accepted` is not `true`. | Send `true`: the account holder's own attestation. | | `403 permission_error` | The account is not eligible to activate links: Bridge verification and terms are incomplete. Checked before the destination. | Finish verification and accept the terms in the dashboard. | | `409 wallet_not_found` | Crypto-only draft, and the account has no Swaps Wallet yet. | Create your Swaps Wallet first. | | `409 capability_unavailable` | Crypto-only draft, and the crypto rail is off right now. | Try again later, or use a saved destination. | | `409 conflict` | The link is no longer a `draft`. | Read it with `GET /v1/payment_links/{id}`; it may already be active. | | `503 temporarily_unavailable` | A dependency did not answer: `error.details.reason` is `settlement_provider_unavailable` (the payment provider) or a failed read such as `account_read_failed`. | Read the link, then activate again with a new `Idempotency-Key`. | 3. Share `url` with the payer. On production it is `https://swaps.app/pay/plk_…`; on the development project it is `https://staging.swaps.app/pay/plk_…` (see [Environments](/environments)); it is `null` while a project has no payer host configured. The last path segment is the payer's `plk_…` session token. Treat it as a secret. 4. The payer side uses public `payment_sessions` calls. They need no key and no `Idempotency-Key`: - `GET /v1/payment_sessions/{token}` is a pure read of the payer's view: amount, `rails[]` and status. - `POST /v1/payment_sessions/{token}/view` moves the link `active → viewed`. Only this call does that. - `POST /v1/payment_sessions/{token}/payments` selects a rail, for example `{ "rail": "ach", "payer_type": "business" }` on a USD link. `crypto_bridge` also needs `source_chain` and `source_asset: "USDC"` (`source_address` is optional); `crypto_tempo` needs nothing more, because the network comes from the link's settlement. The answer is `201` with deposit instructions. Calling it again for the same rail returns the same instructions. - While that payment waits for the money, `GET /v1/payment_sessions/{token}` also returns `pending_payment`: its `rail`, `status` (`awaiting`, `detected` or `processing`) and the same receiving instructions (`deposit_instructions` for a crypto rail, `bank_deposit_instructions` for a bank rail). A payer who opens the link again in another browser still sees where to send the money. `payer_marked_sent` is `true` once the payer reported sending: show the instructions as reference, not as a call to pay. It is `null` once the payment is paid, fails, expires or gets a verdict, while money already sits on another payment of the link, when a Relay quote's send-by time passes, and on a closed or expired link. For `crypto_relay`, send exactly the amount on that network before `expires_at`; any Relay refund goes to the refund address given when the payment was started. It never carries the payment id or anything else about the payer. - `pending_payment` is always in the answer: `null` when nothing is waiting, otherwise the object above. A `crypto_tempo` payment that arrived short is not in it; it is in `underpaid_payment`. At most one of the two is non-null. - `underpaid_payment` is `null` or `{ rail, amount_missing, amount_received, deposit_instructions, created_at }`. It is set only while the link is `processing` and not past `expires_at`, and the payer's latest payment is a `crypto_tempo` payment that arrived short. `amount_missing` is what is still owed (the invoice plus the 1% fee, minus `amount_received`), at the token's own scale and always positive. `deposit_instructions` name the one token the running total counts, and their `amount` is `amount_missing`: send that now. A deposit in another accepted token is held for support and never added. It is `null` for `crypto_relay` (a Relay deposit address belongs to one quote, so there is no top-up), for bank rails and `crypto_bridge`, and on a cancelled, paid, closed or expired link. It never carries the payment id. - `amount_verdict` is one verdict on the money, taken from the payment that settled the link or holds its money, not from the newest payment: `exact`, `underpaid`, `overpaid`, `unmatched` or `null`. `null` means no verdict can be proven, and never means `exact`: a Bridge payment settled as paid reads `null`, and so does a link where nothing has arrived, where payments hold money under different verdicts, or where an `exact` match sits beside other money held for the link (an `overpaid` or `underpaid` verdict is still published then). It is an open enum; treat an unknown value like `null`. Read it. Do not compute a verdict from `amount_received` and `amount_expected`. 5. `GET /v1/payment_links/{id}` or the MCP tool `check_payment_request` shows whether the link is paid. `GET /v1/payment_links/{id}/payments` lists each attempt. Test mode doesn't cover this flow. The keyed steps (1, 2 and 5) are `x-swaps-test-mode: unavailable`, so a `sk_test_` key gets `503 temporarily_unavailable` there (see [Test mode](/test-mode)). The payer steps (4) take no key, so test mode does not apply: a test-mode link's token lives on DEV and is `404` on production. ## When a dependency is down Merchant calls (`create`, `update`, `activate`, `cancel`, `send_invoice`, `reminder_schedule.*` and the reads) answer `503 temporarily_unavailable` with `error.details.reason` when something they depend on did not answer, instead of `500 internal_error`: | `error.details.reason` | Meaning | |---|---| | `settlement_provider_unavailable` | The payment provider did not respond while `activate` set up your destination. The link is still a `draft`. | | `link_read_failed`, `account_read_failed`, `wallet_read_failed`, `address_book_read_failed`, `counterparty_read_failed`, `items_read_failed`, `attempts_read_failed`, `links_list_failed`, `events_read_failed`, `eligibility_lookup_failed` | A read failed on our side. | A `GET` sends `Retry-After`. A call that took an `Idempotency-Key` does not, because that key now answers `409 idempotency_failed`: read the link to see where it stands, then retry with a new key. `send_invoice` answers `503 mail_unavailable` (`error.details.reason: invoice_email_not_sent`) when the email could not be confirmed as sent. It may not have left, the payer's address may not accept mail, or it may already have arrived. There is no `Retry-After` and the same `Idempotency-Key` answers `409 idempotency_failed`: read the link before sending again with a new key. If the payment provider refuses to cancel an open transfer (for example because the payer's funds are already moving), `cancel` answers `500 internal_error` and the link stays as it was. Anything else that fails on our side is also `500 internal_error`, including a failed write: quote its `request_id`. ## Rails `allowed_rails` narrows what the payer may pick; leave it unset and the link offers every rail available for its currency. It is a three-state field on `PATCH`, and the states are distinct on purpose: | You send | What happens | |---|---| | nothing | The stored restriction is left exactly as it is. | | `"allowed_rails": null` | The restriction is removed — the link goes back to every rail its currency offers. | | `"allowed_rails": ["ach"]` | The restriction is replaced. | | `"allowed_rails": []` | `400`. A link restricted to no rail cannot be paid, so this is a mistake, not a way to clear it. | A restriction can only narrow what the link can be paid on, so one that leaves it with nothing a payer could pick is refused rather than stored — `400 allowed_rails_invalid_for_currency` at create and at any `PATCH` that changes the currency or the rails, `409` at activation, where the offending value is the stored draft. Every one of those answers carries the rails that would work in `error.details.offerable_rails`. All three use the same yardstick: what the link could actually be paid on, exactly as the payer page computes it. So a rail you are endorsed for counts even when it is not your link's own currency (a SEPA-endorsed merchant can keep `["sepa"]` on a USD link — the payer pays in EUR), and a link that settles to crypto is never refused for a bank-rail restriction, because it can still be paid in stablecoin. ### Which rails a link lists `payable_rails` (and `payable_rail_kinds`) on the merchant's read are computed live on every read. They are not frozen at activation: they follow what the payer page would offer right now, so the list can change after the link is active. `crypto_relay` is listed exactly when the payer session offers it. That needs the Relay switch on for this merchant, the amount within the per-invoice cap, a USD link, and a Tempo mainnet settlement: on a Tempo test network `GET /v1/capabilities?product=payment_links` reads the `crypto_relay` corridor `not_enabled` with `blocked_reason: relay_requires_tempo_mainnet` and `source_chains: []`, and the payer is not offered Relay. If the switch or the cap cannot be read, the merchant read leaves `crypto_relay` out instead of claiming it. One known exception (#3977): for an individual merchant whose bank pay-in is not active yet, `payable_rails` still lists the bank rails, while the payer session withholds them (`merchant_fiat_payin_pending`, below). For what a payer can actually pick, read `rails[]` on the payer session. ### Rails the payer cannot pick `GET /v1/payment_sessions/{token}` returns `rails[]`, the rails the payer can pick, and `unavailable_rails[]`, the rails this link could take in principle but this session cannot. Each entry is `{rail, reason_code}`, so a payer page can show the row muted with an honest reason instead of hiding it. Both lists come from the same eligibility check, never overlap, and `POST …/payments` refuses every rail in `unavailable_rails` with `400 rail_not_allowed`. A rail your `allowed_rails` excludes is in neither list. | `reason_code` | Meaning | |---|---| | `merchant_fiat_payin_pending` | The merchant is an individual whose bank pay-in is not active yet. The payment refusal carries the same value in `error.details.reason`. A link that settles to a bank account gets `settlement_requires_crypto` instead, because its bank rails never open. | | `settlement_requires_crypto` | The link settles to a bank account. There is no bank-to-bank route, so only a crypto pay-in can settle it. | | `merchant_individual_rail_blocked` | This rail is never payable to an individual merchant (`pix`, `faster_payments`). | | `merchant_rail_not_enabled` | The merchant's account is not enabled for this rail. | | `link_currency_unsupported` | The rail cannot settle this currency (`crypto_tempo` is USD only). | | `amount_below_rail_minimum` | The invoice is under the rail's minimum. | | `rail_disabled` | Swaps has switched this rail off for the link. It is an operator switch, not the merchant's choice, so do not word it as one. A rail Swaps has not launched for this merchant (`crypto_bridge` before it is switched on) is in neither list. | | `rail_temporarily_unavailable` | Eligibility could not be checked right now (for example, no FX rate), so the rail is held back. | | `rail_not_offered` | No more specific reason applies. | Limits that depend on who pays (the individual-payer caps) are not in this list. Those rails stay in `rails[]`, and the payment answers `422` with the specific code once the payer declares `payer_type`. A rail that is switched on but not configured to settle right now answers `503 rail_unavailable` (`error.details.rail`, `error.details.reason`). Nothing is charged and no payment attempt is recorded, so selecting the same rail again once it is configured starts a fresh attempt; until then, offer the payer another rail. A Tempo RPC outage (`error.details.reason` `tempo_rpc_unavailable`) is transient and sends `Retry-After`. ## The Swaps fee The fee is 1% of the invoice, paid by the payer on top of it, on `crypto_tempo` and `crypto_relay` only. You receive the full invoice. `GET /v1/capabilities?product=payment_links` publishes it as `fee`: `kind: "percentage"`, `bps: 100`, `applies_to: "payer"`, `rails: ["crypto_tempo", "crypto_relay"]`, `rounding` (`floor`) and `rounding_decimals` (`6`). A rail that is not in `rails` carries no fee claim from this object: any fee on a bank rail or `crypto_bridge` is applied by Bridge and is not published here, and `fee` on the payment is `null` there. Provider costs (Relay relay and gas, Bridge network and processing) are not this fee and are quoted per payment. The fee is a price, so it is published even while `crypto_tempo` or `crypto_relay` read `not_enabled` in `corridors[]`. The payer is asked for `floor(amount × 10^rounding_decimals × bps / 10000)` base units of the settlement token on top of the invoice. On these two rails the payment's `fee` and `amount_expected` are at the token scale (six decimals), not in cents: a 12.63 USD invoice reads `fee` 0.1263 and `amount_expected` 12.7563, the figures in the deposit instructions. A USD invoice with at most two decimals is never rounded. Show a `Money` at its own `decimals`. ## When money arrives and no payment matches A payment ends `unmatched` in exactly two ways. `unmatched_reason` says which. It is on the payment (`GET /v1/payments`, `GET /v1/payments/{id}`, `GET /v1/payment_links/{id}/payments`, the MCP tools `list_payments` and `get_payment`), on the payer's own payment, and in `data.object` of the `payment.unmatched` event (see [Events and webhooks](/events-webhooks)). | `unmatched_reason` | Meaning | |---|---| | `partial_before_cancel` | The payer had already sent part of the amount (the payment was `underpaid`) when you cancelled the link. `amount_received` is that partial amount. | | `deposit_after_close` | Money was first seen at the payment's settlement address after the payment had closed unpaid: it expired, or the link was cancelled before any money was seen. `amount_received` is what arrived. It may have been sent just before the close, because Swaps polls and a `crypto_relay` payment can still be bridging. | `unmatched_reason` is set only when `status` is `unmatched` and is `null` on every other status. It is also `null` on a payment that became `unmatched` before the field existed: no reason was recorded, so show neutral wording. The list is an open enum; treat an unknown value like `null`. Either way the money is held and handled by support. Swaps never releases or refunds it automatically. On the same payment, `amount_received` is what Swaps has observed and `amount_missing` is what an `underpaid` payment still owes. `amount_missing` is `null` when Swaps cannot prove one positive figure in one token, never a figure across two token scales. ## Common tasks | Task | How | |---|---| | Create a link | `POST /v1/payment_links` — see the [reference](/reference). | | Limit it to one rail | `PATCH …` with `allowed_rails: ["sepa"]` on a EUR link. | | Drop the rail limit again | `PATCH …` with `allowed_rails: null` — `[]` is a `400`, not a clear. | | Choose where funds land | `settlement_destination: { "address_book_id": "adr_…" }` at create or `PATCH`; `activate` refuses an ordinary draft without one (`400 settlement_destination_required`). | | Make it crypto-only | Set `accepted_rail_kinds: ["crypto"]` at create, USD only. There is no separate `settlement_kind` input; the router sets it at activation. | | Activate it | `POST …/activate` with `attestation_accepted: true` — REST-only, excluded from MCP. | | Email it to a client | `POST …/send_invoice`, optionally setting the reminder schedule. | | Check whether it's paid | `GET /v1/payment_links/{id}`, or `check_payment_request` from an agent. | | See if a payment came up short | `partial_payment` on the same response, set only while `status` is `processing` (§8.4) — `received` below `expected`, or `null` while the settled amount is still unconfirmed. | | See what's still open | `GET /v1/payment_links?status_group=open`. | | See which rails the link offers now | `payable_rails` on `GET /v1/payment_links/{id}` or `check_payment_request`: computed live, and it lists `crypto_relay` when the payer session offers it. | | See how much a payer still owes | `underpaid_payment.amount_missing` on the payer session, or `amount_missing` on the payment (`get_payment`). | | Read the verdict on the amount | `amount_verdict` on `GET /v1/payment_sessions/{token}`. Read it; never compute it from `amount_received` and `amount_expected`. | | See why a payment is `unmatched` | `unmatched_reason` on `get_payment` or `GET /v1/payments/{id}`, and in the `payment.unmatched` event. | | Read the Swaps fee before you create the link | `fee` in `GET /v1/capabilities?product=payment_links`; see [The Swaps fee](#the-swaps-fee). | | Get the receipt for a paid link | `GET /v1/payment_links/{id}/receipt` — see [Receipt](#receipt) below. | ## Receipt `GET /v1/payment_links/{id}/receipt` returns the merchant's receipt as JSON. It exists only while the link's own `status` is `paid` or `settled` and exactly one payment completed it. Every other state — draft, open, `processing` (a short payment held for review included), expired, cancelled, refunded — answers `409 receipt_not_available`; a link you do not own is `404`. | Field | What it is | |---|---| | `amount` | What the link asked for — the ask, not an observation. | | `payment_id`, `payment_rail` | The payment that completed the link and its rail. | | `paid_at`, `settled_at` | When the link became `paid`, and `settled` (`null` until then). | | `provider_reference` | The provider's transfer id, or the deposit id for a bank payment collected on a collection account; `null` for `crypto_tempo`. | | `onchain_tx_hash`, `amount_received` | The on-chain hash and the observed deposit, for `crypto_tempo` only. The deposit includes the payer-borne fee, so it exceeds `amount`. | | `fee`, `net_amount` | The same values `GET /v1/payments/{payment_id}` returns: the 1% payer-borne fee, and what you receive once the payment settles (`null` before). | | `settlement_kind`, `settlement_destination` | Where the funds land, as on the link — a saved address book reference, never a raw address or bank detail. | | `recipient_amount` | Omitted today. The received figure is recorded on the link's `paid` event but is not yet published as a provider-confirmed amount, and it is never filled with the invoice amount. | | `title`, `invoice_number`, `note` | Copied from the link; `note` is its memo. | There is no PDF or HTML version yet. The payer gets an e-mail receipt when they left an address on `/pay`. Test mode refuses this operation. Next: the [Create reference](/reference) · [Errors](/conventions#errors) for what a failed create looks like. --- ## Document: Pay an invoice Send stablecoins that settle as a fiat bank payout for a supplier — funded with crypto, paid out in the currency they invoiced in. URL: /products/pay-invoice # Pay an invoice **Status:** Available Send stablecoins that settle as a fiat bank payout for a supplier — you fund with crypto, they receive their own currency in their own bank account. ## Concept A payout is a draft against one beneficiary and one corridor (currency + fiat rail). Nothing is charged until you fund it, and funding is its own explicit step — never bundled into creation. ## Resources | Object | What it is | |---|---| | `payouts` | The payout itself — corridor, invoice amount, fee, settled or shortfall amount once known. | | `beneficiary` | Bank details for exactly one payout, submitted once. **REST-only** — never an MCP tool argument, because raw bank details cross the boundary once and are then held only by the provider. | | `funding_instructions` | The deposit address and estimated source amount to fund a draft payout. This is the money boundary — show it to a human and get per-transaction confirmation before funding. | | `wallet_funding_quote` | Whether the payer's own Swaps wallet balance can fund this payout, on which chain, what must arrive, what leaves the wallet (the cross-network leg's cost included) and whether the balance covers it. Read-only, dashboard session only. | | `receipt` | The confirmed settlement record, once one exists. | | `attempts` / `events` | The funding attempt history and the payout's own event log. | ## Lifecycle `draft → awaiting_funds → funds_received → processing → paid → settled`, with `paid_with_shortfall` as a **terminal** branch (it produces no receipt and never becomes `settled`) and `failed` / `returned` / `expired` / `cancelled` as the other exits. ## Minimal flow 1. `POST /v1/payouts` — draft it against a corridor from [`/v1/capabilities?product=payouts`](/products/wallet). 2. `POST /v1/payouts/{id}/beneficiary` — the bank details, once, over REST. 3. `POST /v1/payouts/{id}/funding_instructions` — get the deposit address; this is the money boundary. 4. Send the crypto to that address; the payout advances as the provider confirms funds and pays out. In the dashboard, the payer can fund from their Swaps wallet instead (`{"funding_source": "swaps_wallet"}` in step 3, a dashboard session only — a business key is refused): the response adds a `wallet_send_intent` whose steps the payer signs with their own wallet passkey, then records with `POST /v1/wallet/send_intents/{id}/source_tx`. Swaps never signs. When a response carries `funding_source: swaps_wallet`, the wallet already funds the payout — do not send from another wallet. Pass `{"source_chain": "…"}` to choose the chain for an external wallet — only before the first funding. 5. `GET /v1/payouts/{id}` or the MCP tool `get_payout` to check status; `GET .../receipt` once settled. > **The fee is 1% of the gross source amount** > > Not invoice × 1.01. The fund ordering re-verifies the corridor and the payer from scratch on every attempt — a create-time capability check never authorizes a transfer by itself. ## What an agent can and cannot do here An MCP agent can draft a payout (`prepare_invoice_payout`), fetch funding instructions (`get_payout_funding_instructions` — the money boundary, still requires the person's own confirmation before sending), and read status (`get_payout`, `list_payouts`) or cancel one that hasn't received funds yet (`cancel_payout`). Adding a beneficiary is structurally excluded from MCP — it is REST-only, because raw bank details are the kind of argument clients log verbatim. Next: the [reference](/reference) for the full payout schema · [Providers & coverage](/providers-coverage) for which corridors are actually open right now. --- ## Document: Customers & verification Start and track verification for your own account. Status only — no document, name or personal detail ever crosses this surface. URL: /products/customers # Customers & verification **Status:** Dark-flag — live behind a cohort or flag Start and track verification for your own account. Every verified flow — payment links, payouts, payroll, bank rails — begins here. This surface carries status only: the person being verified completes verification themselves, on a hosted page run by our verification partner. > **Dark-flag** > > Every `customers.*` operation is gated behind the `api_v1.customers` flag. Until it is enabled for your account the routes answer `503 temporarily_unavailable`. Test mode is not available: a `sk_test_` key cannot create a customer (`POST /v1/customers` answers `503 temporarily_unavailable`), because verification on the partner side is a live, billed step. ## Concept A customer is **your own account's holder** — a singleton, never a customer-of-a-customer. An account has at most one customer, and you cannot verify a third party through this API. Its id (`cus_…`) comes back from `POST /v1/customers` and from `GET /v1/customers`; every `{id}` path below takes it. The literal `me` is a **deprecated alias** for that id: it is accepted until the next `Swaps-Version` date, then removed — do not build on it (see [Conventions](/conventions#naming)). Any other id — someone else's, a mistyped one, or yours before the customer exists — answers `404 not_found`, never a `403`, so the API never confirms that another account's customer exists. `GET /v1/customers` exists for list-shaped clients. It always returns zero or one item, and it returns an empty list, not a `404`, before `POST /v1/customers` has been called. ## Resources | Operation | Method & path | Scope | |---|---|---| | `customers.create` | `POST /v1/customers` | `customers.write` | | `customers.get` | `GET /v1/customers/{id}` | `customers.read` | | `customers.list` | `GET /v1/customers` | `customers.read` | | `customers.verification_links.create` | `POST /v1/customers/{id}/verification_links` | `customers.write` | | `customers.compliance_profile.update` | `PATCH /v1/customers/{id}/compliance_profile` | `customers.write` | | `customers.associated_persons.list` | `GET /v1/customers/{id}/associated_persons` | `customers.read` | `create` and `create_verification_link` act as the account's oldest **owner**. A bearer session must be an owner. A business key reaches both routes, because minting the key was already an owner or admin decision. See [Authentication](/authentication). ## Minimal flow 1. `POST /v1/customers` with `{"customer_type": "individual"}` → `201`, `verification_state: not_started`. The response carries no link. 2. `POST /v1/customers/{id}/verification_links` with `{"kind": "tos", "return_to": "https://swaps.app/"}` → `201 { url, expires_at }`. The holder opens `url` and accepts the terms. 3. The same call with `"kind": "kyc"` → the hosted identity (or business) verification link. The holder completes it themselves. 4. `GET /v1/customers/{id}` — or subscribe to `customer.verification_state_changed`, `customer.requirements_updated` and `customer.rejected` (see [Events & webhooks](/events-webhooks)) — until `verification_state` is `approved`. ## Creating the customer: no personal data in the body The body takes three fields and nothing else. An unknown field is `400`. | Field | Required | What it is | |---|---|---| | `customer_type` | yes | `individual` or `business`. A day-zero seed: once verification resolves, the verified type is authoritative. | | `country` | no | ISO 3166-1 alpha-2, validated against the same market registry `POST /v1/accounts` uses. Stored only if the account has no country yet — never overwrites one. | | `legal_name` | no, `business` only | The **registered company name**, 1–200 characters. A company's registered name is not personal data. On an `individual` request it answers `400 invalid_request` with `param: legal_name`. | The body never carries a natural person's name or email. Swaps resolves the account owner server-side and uses the owner's own email, plus, for an individual, the owner's name when a real first and last name is already on file. When no name is on file, verification still starts: the hosted flow reads the legal name off the identity document itself. A second `POST /v1/customers` for the same account answers `409 customer_already_exists` — read `GET /v1/customers/{id}` instead. A concurrent or retried create for a **different** `customer_type` answers `409 customer_type_conflict`: decide which type you meant, then retry with a fresh `Idempotency-Key`. ## Minting a verification link `kind` accepts five values, but only `tos` (terms of service) and `kyc` (identity or business verification) mint a real link today. Today `business_questionnaire`, `business_ubo` and `remediation` answer `409 capability_unavailable` — an honest refusal instead of a fabricated link. `return_to` is required and must be on `https://swaps.app` or `https://www.swaps.app`; anything else is `400 invalid_request` with `param: return_to`. It is validated but **not yet threaded** into the hosted hand-off: after the hosted page the holder lands on the fixed Swaps page the hand-off uses today, not on your `return_to`. Do not build a flow that depends on the redirect yet. `expires_at` is a conservative 15-minute placeholder, not a value the verification partner publishes. Mint a fresh link each time the holder is about to open one, instead of storing links. A link call first reads the customer's status, so it can answer any of the `404`/`409`/`503` below before it looks at the body. ## Reading status Everything in the read shape is status. The fields you route on use Swaps' own vocabularies, so no provider-specific value appears in them: | Field | Meaning | |---|---| | `verification_state` | The canonical state: `not_started`, `pending`, `under_review`, `approved`, `rejected`, `stale_mapping`, `action_required`. An open enum — treat an unknown value as opaque, never as an error. | | `requirements_due[]` | What blocks progress. Route on `resolution` (`form`, `hosted_kyc`, `tos`, `info`) and show `display_label`. Never show the raw `slug` to a user — it is an identifier, not copy, and not yet a closed list. | | `endorsements[]` | The rails the customer is cleared for: `base` (the stablecoin rail every customer needs first), `sepa`, `spei`, `pix`, `faster_payments`, `cop`. Open list — ignore a code you do not know. Per-rail detail is in `endorsement_statuses[]`, each item keyed by its own `endorsement`, never paired by index. | | `tos_status` / `kyc_status` | Supporting detail, not yet normalized into a closed enum. Treat anything other than `approved` as "not done yet". `verification_state` already folds `kyc_status` in — route on that. | | `rejection_outcome` / `rejection_guidance[]` | For `rejected`: an outcome code and bounded, safe copy — never the partner's raw reason. | | `degraded` / `stale` | `degraded: true` means a cached status (the live read was not reachable). `stale: true` means onboarding must restart. Neither is ever collapsed into another state. | A `form` requirement is answered with `PATCH /v1/customers/{id}/compliance_profile` (owner or admin). It is write-only: it answers `202 {accepted: true}` and never echoes the values it receives. ## Two failures you must not conflate A customer that exists but whose live status cannot be read answers one of two errors. They need opposite handling: | Answer | Class | What to do | |---|---|---| | `409` `conflict` / `customer_mapping_stale` | **Permanent.** Our record of this customer no longer matches our verification partner's, and an operator has to repair it. There is no `Retry-After`. | Stop retrying and surface it — contact support. Retrying, even with a fresh key, will not fix it. | | `503` `temporarily_unavailable` / `customer_status_unavailable` | **Transient.** The status read failed this time. Carries `Retry-After`. | Retry after the stated delay. Never treat it as "no customer yet" — that is only ever `404 not_found`. | `GET /v1/customers/{id}`, `GET /v1/customers` and the link mint all give the same answer for the same account. `POST /v1/customers` can also answer the `503` right after a successful start, when the new record is not visible yet. Then read `GET /v1/customers/{id}` rather than creating again: a repeat create answers `409 customer_already_exists` once the record is visible, and never starts a second verification. ## What never crosses this surface No document, name, date of birth, address or tax id is ever accepted or returned here — not in request bodies, responses, events, URLs or MCP tool arguments. A business's registered `legal_name` on create is the one exception, and it is not personal data. Beneficial owners appear as a count (`associated_persons_count`), never by name. The associated-persons list (`GET /v1/customers/{id}/associated_persons`) carries a `label` and an optional `ownership_percent` per person, never a name. ## What an agent can and cannot do here An agent can start verification for the one holder it acts for (MCP tool `start_verification`) and read status (`get_verification_status`). Minting a hosted link has no MCP tool yet — it is REST only. An agent can never submit identity data, upload documents or complete verification on the holder's behalf — the person does that on the hosted page. Next: the [reference](/reference) for the full `Customer` schema · [Errors](/errors) for every code above. --- ## Document: Crypto processing A crypto-only payment link plus recurring subscriptions, paid one tap at a time by the payer — never an authorised pull. URL: /products/crypto-processing # Crypto processing **Status:** Dark-flag — live behind a cohort or flag A crypto-only payment link plus recurring subscriptions, paid one tap at a time by the payer — never an authorised pull in v1. > **Live with dashboard v2 (2026-10-08)** > > Crypto processing is gated by `payment_links_crypto_only` (crypto-only links and subscriptions), `api_v1.payment_links` (every payment-links, clients, products and payments operation) and `api_v1.subscriptions` (the subscription operations). While `api_v1.payment_links` or `api_v1.subscriptions` is off, its operations answer `503 temporarily_unavailable`. While `payment_links_crypto_only` is off, creating a crypto-only link, or creating or resuming a subscription, answers `409 capability_unavailable`, and existing subscriptions issue no new invoices. While the crypto rail itself is paused, activating a crypto-only link answers `409 capability_unavailable` too. ## Concept Two related surfaces: 1. **A crypto-only payment link** — an ordinary `payment_links` object with `settlement_kind: crypto_only` and `accepted_rail_kinds: ["crypto"]`. It activates on a Bridge-less eligibility branch (a healthy Swaps Wallet and accepted payment-link terms, no Bridge customer required) and settles straight to the merchant's own wallet. 2. **Subscriptions** — a version-1 `scheduled_invoices` kind: a series of invoices reissued on a fixed interval (`monthly` / `quarterly` / `yearly`), each one an independent crypto-only payment link the payer pays themselves. No card-style authorised pull exists in v1 — that is an explicit v2-shaped kind reserved in the schema, not something v1 can do. ## Resources | Object | What it is | |---|---| | `payment_links` (crypto-only) | The same object as [Payment links](/products/payment-links), constrained to crypto rails. | | `payments` (crypto) | Adds verdict states `underpaid`, `overpaid`, `unmatched` on top of the ordinary payment lifecycle. An `unmatched` payment carries `unmatched_reason` (`partial_before_cancel` or `deposit_after_close`, `null` on rows from before the field existed) and the `payment.unmatched` event carries it too. The money is held for support, never released or refunded automatically. See [Payment links](/products/payment-links#when-money-arrives-and-no-payment-matches). | | `subscriptions` | The schedule itself — amount, interval, next due date, the merchant's settlement wallet, plus a derived (never stored) `overdue` flag. | | `subscription invoices` | One issued invoice per cycle — `not_issued`, `open`, `paid`, plus a derived (never stored) `overdue` flag. | ## Fee and minimums (locked) The payer pays the invoice plus 1% in one transfer; the merchant receives the exact invoiced amount — the Tempo watcher has zero tolerance for a partial match. Minimum invoice: **$5** in the dashboard, per payment and per subscription period; the API does not enforce that floor on crypto-only links or subscriptions. Accepted stablecoins: USDC.e, pathUSD, USDT0, USD1, cUSD (all 6 decimals). Crypto processing is USD only. ## Minimal flow 1. `POST /v1/subscriptions` — create the schedule; no money moves yet. 2. On each due date, an invoice is issued as its own crypto-only payment link. 3. The payer pays that link directly — the same flow as any [payment link](/products/payment-links). 4. `GET /v1/subscriptions/{id}/invoices` for payment history; an overdue invoice does not pause or cancel the subscription by itself. Crypto processing has no e-mails of its own yet, and the payer is not e-mailed the invoice: share each link with the payer yourself, and follow payments and subscription invoices through [webhooks](/events-webhooks) and the dashboard. ## What an agent can and cannot do here On the hosted MCP server, `create_subscription` and `get_subscription` are `dark-flag`: the server does not register them yet, even while the switches in the note above are on. Until it does, an agent uses the REST operations above, which follow those switches. `create_subscription` drafts the schedule only; `get_subscription` returns one subscription and its derived `overdue` flag, not its payment history. Nothing about a subscription authorizes automatic payment — every invoice is its own payer-initiated payment link, never an authorised pull the API triggers on the merchant's behalf. Next: [Payment links](/products/payment-links) for the machine this reuses · [Changelog](/changelog) for changes to this surface. --- ## Document: Buy & sell Quote and route a crypto purchase or sale across every connected provider — one best executable route, with the fee breakdown shown up front. URL: /products/buy-sell # Buy & sell **Status:** Available Quote and route a crypto purchase or sale across every connected provider — one best executable route, with the fee breakdown shown up front, not discovered at checkout. ## Concept A quote prices a side (buy or sell), an asset pair and an amount across every connected provider and returns the best executable route. An order is what you get when a quote is turned into an actual attempt to transact with that provider. ## Resources | Object | What it is | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `quotes` | `rate`, `final_out`, `final_in`, `total_fees`, `expires_at`, plus the winning `offer_id`, every priced `offers[]` route, and terminal per-provider outcomes (`quoted`, `no_offer`, `paused`, `error`) from the same quote call. | | `orders` | The attempt itself — `status` is the public lifecycle; **`money_state`** (`never_authorized` / `hold_placed` / `captured`) is the actual money truth, never inferred from `status` alone. Published only once a row has been classified — absent otherwise, and absence is never `never_authorized` or any other value. | ## Lifecycle Quotes are `ready` or `indicative` and always carry an `expires_at` — re-quote rather than reuse a stale one. An order's public `status` is the one lifecycle field on the wire (`draft`/`quote_ready`/etc. is not a separate enum — the hand-off to a provider's own checkout is read from whether `provider_url` is present); `money_state`, when classified, is reconciled with it on the same object, never a second, disagreeing status field. `checkout_readiness` carries provider-specific handoff and freshness facts. `ready` means the quote passed those checks; it does not guarantee the final provider price, verification eligibility or settlement. An indicative quote retains its actual `indicative_as_of`; re-quote live before acting. `providers` contains one row per provider observed in the existing call, ordered by provider ID. A usable offer wins over a failure on another payment method for the same provider. Each quoted row includes its selectable `offer.offer_id`. A route with no usable offer returns `409 capability_unavailable`, with safe terminal outcomes in `error.details.providers` when available. Raw diagnostics and provider payloads are private. `offers[]` contains every fully priced route from that fan-out, including sibling payment methods. Each entry binds the canonical `payment_method` when known and opaque `offer_id` to its provider, readiness, expiry, resolved limits and non-PII `execution_context` (assets, amount side, and supplied route selectors). Use entries with a known method when presenting or selecting a method; unknown methods remain visible but are never inferred. Capabilities are not substituted for missing quotes. `rate` — on the winner, every `offers[]` entry and every `providers[].offer` echo — is each offer's own `final_in` decimal value ÷ `final_out` decimal value, computed BEFORE either is rounded into the published `Money` fields, one basis (`from_asset` per `to_asset`) computed the same way for every offer regardless of provider or side. Because the derivation runs pre-rounding, `rate` × the published `final_out` amount can differ from the published `final_in` amount in the last digit(s) when an asset's published decimals are coarser than the server's own internal precision — treat that cross-check as approximate, not exact. It is informational only: never rank or compare offers by `rate`. For a `from_amount` request, compare by `final_out` instead — every offer targets a different `final_out` for the same pay amount, so the largest wins. For a `to_amount` request, compare by `final_in` only among offers whose `final_out` equals the requested amount — the lowest pay amount among those wins. An offer whose `final_out` differs from the requested amount is not comparable this way: not every provider honors an exact-output target. The winner and each provider offer may include `payment_method`, the canonical method priced for that specific `offer_id`. Preserve both when presenting or selecting an offer. A different offer from the same provider can use another method; missing `payment_method` means unknown, so do not copy it from the request or winning offer. The field describes the priced payment intent and does not establish customer eligibility or guarantee the eventual settlement rail. For Transak offers, specify the crypto network (`to_network` when buying, `from_network` when selling). An unspecified network cannot authorize its checkout. Other usable provider offers remain available; when none remain, `409 capability_unavailable` includes `QUOTE_NETWORK_REQUIRED` and the network field to provide. Anonymous quoting stays on the existing public widget transport. Test-mode `/v1/quotes` requests currently return `503 temporarily_unavailable` before any provider request; sandbox dispatch remains pending. When sent, `country` must be an uppercase ISO 3166-1 alpha-2 code; a comprehensively-sanctioned or unsupported one is refused with `409 capability_unavailable` before pricing runs, even when `country` is omitted and only inferred from a forwarded geo header. ## Minimal flow 1. Authenticate with a business key or bearer holding `orders.write`, then call `POST /v1/quotes` — or the MCP tool `get_quote` — with side, assets, amount, `to_network` (required later to create a Bridge-native order from this quote) and (when known) country and payment method. 2. Present the route and its fees to the person; quotes expire, so don't hold one past its `expires_at`. A quote or selectable `offer_id` alone never authorizes execution — the person must see the route and its fees first (see [What an agent can and cannot do here](#what-an-agent-can-and-cannot-do-here) below). 3. Create the order: `POST /v1/orders` with `quote_id`, the chosen `offer_id`, and `wallet_address` — required for a Bridge-native buy. A test-mode (`sk_test_`) key cannot create an order — `orders.create` answers `503 temporarily_unavailable` ("Test-mode orders are not available yet") before any other check, and releases the idempotency reservation; see [Test mode](/test-mode). Step 3 is a live-key call that moves real money. It completes in place, landing directly on an active `status`, with no separate hand-off page. This release only a **Bridge-native BUY** offer creates this way: check the offer's own `checkout_readiness.handoff_mode` before calling create — only `backend_native` is live here today. Every other selectable offer is a typed, non-retryable refusal, not a bug: - A quote priced without `to_network` cannot become a Bridge-native order: answers `409 capability_unavailable` (`param: quote_id`), "This quote has no to_network — request a new quote with to_network set before creating a Bridge-native order". Re-quote with `to_network` set and retry. - A **Bridge-native SELL** offer (same `handoff_mode`, `side: sell`) answers `409 capability_unavailable` (code `capability_unavailable`), "Bridge-native sell orders are not created through this endpoint yet" — a Sell's payout destination has no field on `OrderCreateRequest` yet. Never retry as-is. - Any **hosted-provider** offer — `checkout_readiness.handoff_mode` other than `backend_native` — answers `409 capability_unavailable` (code `capability_unavailable`), "`` checkout is not available through this endpoint yet". Never retry as-is; a future release returns a `provider_url` to open instead. - A **Paybis** offer answers `409 capability_unavailable` (code `provider_paused`) — Paybis's checkout path is frozen pending a product/security review, defense-in-depth on top of `POST /v1/quotes` already excluding Paybis from every response. Never retry as-is. The `wallet_address` itself can also be refused by destination screening: `403 permission_error` (code `destination_blocked`) on a flagged address — never retry as-is — or `503 temporarily_unavailable` (code `destination_check_unavailable`) when screening is itself unavailable and the fail-closed policy blocks the create — retry after the response's `Retry-After`. Two more answers are conflicts, not bugs, and worth telling apart from an actual defect: a **stale offer** — past its own `expires_at` or `checkout_readiness.checkout_fresh_until` — answers `409 conflict` (code `requote_required`); request a fresh quote and create the order from the new `offer_id`. A create that **reconciled onto an already-existing order** — made by a prior attempt under a different `Idempotency-Key`, recovered through Bridge's own provider-side idempotency key, rather than creating a second one — still answers `201`, but carries `Idempotent-Replayed: true`; read that header before booking this call as having made a new order. See [Errors](/errors) for the full code reference. 4. `GET /v1/orders/{id}` — read `money_state`, when present, before ever telling a user money moved. > **Never conclude money moved from the status label alone — or from money_state's absence** > > `get_order_status` and `get_payment` both say this explicitly: a classified order can read `completed` while > `money_state` reads `never_authorized`. `money_state` itself is published only once a row is classified — most > rows never get one, so an ABSENT `money_state` means "never classified," not "not charged." Read the field's > presence and value together; never the display status, and never treat absence as a value. ## What an agent can and cannot do here `get_quote` prices a route; nothing about pricing a quote authorizes spending. An order is created from a quote, never chained straight from a quote response without the person seeing the route and its fees first — the same human-in-the-loop rule every money-preparation tool follows (see [Agents & MCP](/agents-mcp#what-a-tool-can-never-do)). Next: the [reference](/reference) for the full quote and order schemas · [Providers & coverage](/providers-coverage) for which providers and countries are actually live.