# Authentication

Every programmatic request authenticates with a **Bearer API key**:

```http
GET /v1/auth/me HTTP/1.1
Host: api.replylayer.ai
Authorization: Bearer rly_live_k3m9p2qx7vn4hjd0.uZ8Qb1vK3mN0pR7sT2wX9yA4cF6gH8jL1nP3rT5vW7z
```

The base URL is `https://api.replylayer.ai/v1`. All endpoints except the auth and
health routes require a key. Session cookies exist for the dashboard only — as an
agent you always use a Bearer key.

## Key format

A key is a single opaque string in two dot-separated parts:

```
rly_live_<public_id>.<secret>
```

- `<public_id>` — the 16-character head after `rly_live_` (lowercased base32). It is
  **not** secret; it identifies the key for leak-attribution and appears (masked) in
  the key-list read. Do not authenticate with it alone.
- `<secret>` — the credential proper (the part after the `.`). The whole token, both
  parts together, is what authenticates.

Treat the entire `rly_live_...` string as the secret. The plaintext is returned
**exactly once**, on the create response — there is no endpoint that re-reveals it.
If you lose it, mint a replacement. A key using a retired prefix (anything other than
`rly_live_`) is rejected at the door with `401 API_KEY_FORMAT_LEGACY` — rotate to a
current key.

## Two roles: admin and agent

| Role | Scope | Can it manage keys / mailboxes / account? |
|---|---|---|
| `admin` | Full account access — same reach as a dashboard session. | Yes. |
| `agent` | Send, receive, read, reply, and quarantine-manage **on its bound mailboxes only**. | No — cannot create/delete mailboxes, mint or list keys, edit scanner policy, or touch account settings. |

An agent key is **bound to one or more mailboxes** at creation (`mailbox_ids`,
non-empty and required for `role: "agent"`; forbidden for `role: "admin"`). It can
never see or act on a mailbox outside that set.

### Fail-to-zero scoping

Agent scope fails **closed**. If every mailbox an agent key was bound to is later
deleted, the key resolves to **zero mailbox access** — never to unrestricted access.
A scoped key that has lost all its bindings can authenticate but cannot send or read
anything; it does not silently widen to "all mailboxes". Deleted mailboxes also drop
out of the key's reported `mailbox_ids` / `api_key_mailbox_ids`.

## Bearer-over-session precedence

If a request carries **both** an `Authorization: Bearer` header **and** an
`rl_session` cookie, the Bearer key wins and the request runs as that key — the cookie
is ignored. This matters most during rotation: a tool that quietly injects a stale
`Authorization` header (a copied browser cURL, an SDK holding an old key) will run the
Bearer branch even when you meant to act as your dashboard session. Send **exactly one**
credential per request.

## Rotation: create → verify → revoke

The zero-downtime pattern for a running integration is three explicit steps. Both new
and old keys are valid simultaneously between step 1 and step 3, so there is no window
where your integration is un-authenticated.

**1. Create a new key** (does not touch existing keys). Admin-only:

```http
POST /v1/accounts/api-keys
Authorization: Bearer <current-admin-key>
Content-Type: application/json

{ "role": "agent", "label": "support-bot", "mailbox_ids": ["<mailbox-uuid>"] }
```

```json
201 Created
{
  "id": "<key-uuid>",
  "api_key": "rly_live_<public_id>.<secret>",
  "role": "agent",
  "label": "support-bot",
  "mailbox_ids": ["<mailbox-uuid>"]
}
```

Save `api_key` now — it is shown only here. An account may hold **at most 10 active
keys**; at the cap the create call returns `403 FORBIDDEN`, so revoke a stale key
first. To replace an agent key with the same reach, create a fresh agent key carrying
the same `mailbox_ids`.

**2. Verify the new key** against an authenticated read before you cut over. Use
`GET /v1/auth/me` (cheap, single response):

```http
GET /v1/auth/me
Authorization: Bearer <new-key>
```

```json
200 OK
{ "account_id": "...", "email": "...", "tier": "...", "status": "active", "mfa_enabled": false, "email_verified": true, "phone_verification_required": true, "phone_verified": true, "phone_number_masked": "•••• 0123" }
```

For a Bearer key the response also carries `api_key_role` (`"admin"` | `"agent"`) and
`api_key_mailbox_ids`, so you can confirm the new key resolves to the role and mailbox
set you expect. **Do not use `GET /v1/health` to validate a key** — health is
auth-exempt and returns `200` regardless of whether the token is valid, revoked, or
absent.

**3. Revoke the old key** once traffic has moved:

```http
DELETE /v1/accounts/api-keys/<old-key-id>
Authorization: Bearer <new-admin-key>
```

```json
200 OK
{ "status": "revoked" }
```

Find `<old-key-id>` in the `id` field of the key list (`GET /v1/accounts/api-keys`).
DELETE on a non-existent or already-revoked key returns `404 NOT_FOUND`. Revocation is
not cached — the revoked key's very next request fails auth.

### The rotate endpoint (destructive, admin-only)

`POST /v1/accounts/api-keys/rotate` mints a new **admin** key and revokes old admin
key(s) in the same transaction — there is no verify window, so any in-flight request
that lands between the commit and your secret-store update fails with `401`. Its blast
radius depends on the caller:

- **Bearer (API key)** — revokes only the calling key, mints one new admin key.
- **Session (dashboard)** — revokes **all** admin keys, mints one new admin key.

Rotate never touches agent keys. Prefer the create → verify → revoke sequence above for
anything you can't afford to 401; use rotate only for a personal session or a compromise
sweep where absorbing in-flight 401s is acceptable.

## Self-revoke

Any key — **including an agent key** — may revoke **itself** by passing its own `id` to
`DELETE /v1/accounts/api-keys/:id`. This lets a compromised or retiring agent burn its
own credential without an admin in the loop; it is strictly capability-reducing. After a
successful self-revoke, that key's next request returns `401`.

Revoking any **other** key still requires an admin key or a dashboard session. An agent
key that targets a different key's `id` gets `403 INSUFFICIENT_SCOPE`.

## Per-key instruction-trust capability (read-only)

Each key exposes a boolean `instruction_trust_enabled` (visible in
`GET /v1/accounts/api-keys`). It is **off by default** and is a **read-path capability
only** — it can, under the right conditions, relax the `agent_safety_context` guidance
an inbound read returns for a granted, verified sender. It grants **no** send or write
power. See [Trusted instruction sources](/agents/trusted-instructions) for exactly when a
read relaxes and how to branch on it.

Two hard rules for an agent integration:

- **An agent cannot enable this for itself.** Turning `instruction_trust_enabled` on is a
  *loosening* action, restricted to a dashboard session with fresh re-authentication. A
  Bearer key — admin **or** agent — that calls the enable route is rejected with
  `403 REAUTH_REQUIRES_SESSION`. There is no client-sent opt-in; the relaxation is applied
  server-side by operator configuration only.
- **Turning it off is unprivileged.** *Disabling* the capability is a tightening action
  and is agent-callable, so an agent can drop its own relaxed reads instantly for incident
  response.

The capability is also **per-key and non-transferable**: a freshly created or rotated key
always starts `instruction_trust_enabled: false`, regardless of what the key it replaced
had. Re-enable it deliberately (session + re-auth) if a replacement key needs it. Until it
is enabled, reads by that key fall back to the standard untrusted-content guidance with no
error surfaced.

## Scope and auth error codes

These are the codes that signal an auth or scope problem. The full, curated error catalog
lives at [/agents/errors](/agents/errors) — branch on `code`, not on the human `error`
string.

| HTTP | Code | Meaning |
|---|---|---|
| 401 | `UNAUTHORIZED` | Missing, malformed, invalid, or revoked Bearer key. |
| 401 | `API_KEY_FORMAT_LEGACY` | Key uses a retired prefix — rotate to a current `rly_live_` key. |
| 403 | `INSUFFICIENT_SCOPE` | An agent key hit an admin-only route (or tried to revoke another key). |
| 403 | `MAILBOX_ACCESS_DENIED` | An agent key acted on a mailbox it is not bound to (collection reads/writes). |
| 404 | `NOT_FOUND` | Single-resource **detail** read of a resource the key can't reach (masked to 404 so existence doesn't leak), or DELETE of an already-revoked/missing key. |
| 403 | `REAUTH_REQUIRES_SESSION` | A Bearer key hit a route that is session-only + fresh re-auth — enabling instruction trust, the loosening attachment gates, or **any MFA-management / SMS step-up ceremony**. |
| 403 | `ACCOUNT_SUSPENDED` | The account is not active. |
| 403 | `EMAIL_NOT_VERIFIED` | The account's email is unverified — most routes stay gated until it is. |
| 403 | `PHONE_NOT_VERIFIED` | The required signup phone is unverified — protected routes stay gated until the SMS check completes. |

Note the masking asymmetry for agent keys reaching outside their scope: a **detail** read
(`GET /v1/messages/:id`, `GET /v1/mailboxes/:id`) returns `404 NOT_FOUND` so it can't be
used to probe for the existence of mailboxes you can't see, while a **collection** read
(`GET /v1/mailboxes/:id/messages` and siblings) returns `403 MAILBOX_ACCESS_DENIED` for an
unbound mailbox UUID (and `404` for an unbound mailbox *name*).

## MFA is a human, session-only concern

Two-factor authentication (TOTP or SMS) protects the human dashboard session, not the
API-key surface. **Do not attempt any MFA route with an API key.** Enrolling, switching,
or disabling a factor, the SMS step-up used to re-authorize a loosening action, and the
lost-factor recovery and phone-change ceremonies are all dashboard-session-only — a Bearer
key (admin *or* agent) is rejected with `403 REAUTH_REQUIRES_SESSION` before anything
happens. The full family of SMS-MFA and step-up codes (`MFA_SMS_*`, `REAUTH_*_SMS_*`,
`MFA_RECOVERY_*`, `PRIVACY_ACK_REQUIRED`) is catalogued for completeness at
[/agents/errors](/agents/errors), but an API key only ever sees `REAUTH_REQUIRES_SESSION`
from this surface.

## Signup verification bootstrap

Every new password, API/CLI, Google, or GitHub account supplies a mobile number.
Existing OAuth logins do not. Protected product routes and provisioning wait for
both email and phone verification; gate precedence is email first, then phone.
The initial API key/session remains valid for these recovery routes while gated:

```http
POST /v1/auth/verify-email
Authorization: Bearer <new-key>
Content-Type: application/json

{ "code": "482917" }
```

```http
POST /v1/auth/verify-phone
Authorization: Bearer <new-key>
Content-Type: application/json

{ "code": "593104" }
```

SMS codes contain six digits, expire after 5 minutes, and only the newest
challenge is valid. Resend with an empty object to
`POST /v1/auth/resend-phone-verification`; before verification, the same endpoint
also accepts `{ "phone_number": "+13125550124" }` to correct a typo and send a
fresh code. Treat `429 PHONE_VERIFICATION_RATE_LIMITED` as a wait instruction and
honor `Retry-After`. `GET /v1/auth/me` returns only `phone_number_masked`, never the
full number.

## See also

- [Messages and state](/agents/messages) — the message lifecycle and verdict vocabulary.
- [Security model](/agents/security-model) — the untrusted-content contract for inbound bodies, attachments, and links.
- [Send gates](/agents/send-gates) — why an agent-origin send may be additionally contained.
- [Errors](/agents/errors) — the full error-code catalog and denial envelope.
