# 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 `<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](/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.
