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. 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 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 answers503 temporarily_unavailable. - Scopes are
<resource>.<read|write>, 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 (<resource>.<read|write>) 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 — 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_membersrole) — the reference states the caller classes an operation accepts, per operation, viax-swaps-caller.agentappearing in that list does not by itself mean a business key can reach the route — only the literal valuebusiness_keydoes; use the table's Business key reaches column above, or check forbusiness_keyinx-swaps-callerdirectly, neveragent.
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 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 · Conventions for what an expired or wrong credential returns.