# auth.md

You are an agent, and you want a credential for the Announcer API. This page is
the whole procedure.

Three ways in. Take the first that fits — none of them puts a password through
you:

1. **[Device grant](#the-device-grant)** (RFC 8628), when a person is in front
   of you. You ask for a code, they approve it in a browser, you poll and get a
   key. Whoever approves is the account.
2. **[Claim ceremony](#the-claim-ceremony-acting-for-a-named-person)**
   (`service_auth` / verified email), when you act for somebody in particular
   and know their address. Same shape, except you name them up front and only
   they can approve — which is what lets the credential be a verified-email
   one.
3. **[Signup](#registering-with-no-human-at-all)**, if there is nobody to ask
   at all and you control a mailbox of your own.

- Canonical: <https://misralo.com/auth.md> — also served at <https://mail.misralo.com/auth.md>
- API base: `https://mail.misralo.com`
- The surface itself: [openapi.json](https://misralo.com/openapi.json) (OpenAPI 3.1), [docs.md](https://misralo.com/docs.md), [/.well-known/api-catalog](https://misralo.com/.well-known/api-catalog)

## Discovery

If you arrived here from a 401, you already have the thread to pull. Every
unauthenticated answer from the API carries it:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="Announcer",
  resource_metadata="https://mail.misralo.com/.well-known/oauth-protected-resource"
```

That document ([RFC 9728](https://www.rfc-editor.org/rfc/rfc9728)) names the
resource, its scopes, and the authorization server — which here is the API
itself. The server's own metadata ([RFC 8414](https://www.rfc-editor.org/rfc/rfc8414))
is at `https://mail.misralo.com/.well-known/oauth-authorization-server` and
names the two endpoints the next section uses, the one grant type they speak,
and an `agent_auth` block pointing back at this page. Both are also served from
`https://misralo.com/` for anything that starts at the brand name rather than
at a 401.

Everything those documents say is checked against the router before it ships,
so you can act on them rather than treating them as a hint.

**What is not there**, so you do not go looking: no authorization endpoint —
there is no browser redirect flow for third-party clients — no dynamic client
registration, and no JWKS. The `agent_auth` block advertises one registration
method, `service_auth`, and its assertion type is `verified_email`. There is no
ID-JAG here and no anonymous registration: nothing is signed, no identity
assertion is issued, and `POST /v1/agent/identity` refuses both by name rather
than leaving you to infer it.

## What you end up holding

An ordinary API key:

- **Credential** — `ann_` followed by 48 hex characters. It does not expire.
- **Presented as** — `Authorization: Bearer ann_…`. The header, and nothing else.
- **Scopes** — `send`, `full`.
- **Revoked by** — `DELETE /v1/keys/{id}`, or by the person, in the dashboard.
- **Belongs to** — one account, which is the account billed for what you send.

A key from the device grant is the same object as a key made by hand in the
dashboard. There is no second class of credential here, which is why
everything below about scopes and revocation applies whichever way you got it.

## The device grant

### 1. Ask

```http
POST /v1/auth/device
Content-Type: application/json

{"client_name": "acme-deploy-bot", "scope": "send"}
```

`201`:

```json
{
  "device_code": "dev_…",
  "user_code": "BCDF-GHJK",
  "verification_uri": "https://announcer.misralo.com/device",
  "verification_uri_complete": "https://announcer.misralo.com/device?code=BCDF-GHJK",
  "expires_in": 600,
  "interval": 5
}
```

Either JSON or `application/x-www-form-urlencoded` — a stock RFC 8628 client
works unchanged, and `client_id` is accepted as another spelling of
`client_name`. There is no client registry here, so nothing is looked up;
whichever you send is what the person is shown.

`client_name` is what the approval screen shows. Send something a person will
recognise as you — it is the only thing they have to go on, and the screen
tells them it is a claim rather than an identity, so a vague one gets declined.

`scope` defaults to `send`, which can send mail and read its own messages. Ask
for `full` only if you must register domains or manage keys, and expect to be
given less.

### 2. Show the person the code

Print `user_code` and `verification_uri`. Offer `verification_uri_complete` as
a clickable link where you can — it fills the code in — but **always show the
short code too**, because it survives being read aloud, retyped on a phone, or
pasted into a chat. Typing is forgiving: case, the dash and stray spaces do not
matter.

Both codes die after `expires_in` seconds. Ten minutes.

### 3. Poll

```http
POST /v1/auth/device/token
Content-Type: application/json

{"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
 "device_code": "dev_…"}
```

Every `interval` seconds — five — and not faster. The answer is `400` with an
`error` until it is `200`:

| `error` | What it means |
| --- | --- |
| `authorization_pending` | Nobody has decided yet. Keep polling. |
| `slow_down` | You polled inside the interval. Wait the full interval and resume; this is not a failure. |
| `access_denied` | The person declined. Final. Do not start a new request hoping for a better answer. |
| `expired_token` | Ten minutes passed. Start again from step 1, with a fresh code to show. |
| `invalid_grant` | Unknown device code, or one already redeemed. Final. |

Errors are RFC 9457 problem documents that also carry OAuth's `error` and
`error_description` at the top level, plus `interval`. Read `error` and
`error_description` if you were written against RFC 8628, or `title` and
`detail` if you were written against this API. Both pairs are always present
and always agree.

### 4. Collect the key

```json
{
  "access_token": "ann_…",
  "token_type": "Bearer",
  "scope": "send",
  "key_id": "…",
  "prefix": "ann_7e74bc"
}
```

**`access_token` is shown exactly once.** Only its hash is stored. Persist it
before you do anything else. `scope` is what was actually granted, which may be
narrower than what you asked for — read it and adjust rather than assuming, or
your first management call will be a surprise `403`. `key_id` is what you pass
to `DELETE /v1/keys/{id}` later.

A device code buys exactly one key. Replaying it answers `invalid_grant`.

## The claim ceremony: acting for a named person

Use this instead of the device grant when you are acting for somebody in
particular and know their email address. The difference is who may say yes: a
device grant is approved by whoever holds the code, and this one only by the
address you name. That is the whole content of the `verified_email` assertion
— the service is not telling you somebody consented, it is telling you the
holder of that address consented.

### 1. Name the person

```http
POST /v1/agent/identity
Content-Type: application/json

{"type": "service_auth",
 "login_hint": "someone@example.com",
 "client_name": "errand-bot",
 "scope": "send"}
```

`201`:

```json
{
  "type": "service_auth",
  "claim_token": "claim_…",
  "claim_expires_in": 3600,
  "claim": {
    "user_code": "BCDF-GHJK",
    "verification_uri": "https://announcer.misralo.com/device",
    "verification_uri_complete": "https://announcer.misralo.com/device?code=BCDF-GHJK",
    "expires_in": 600,
    "interval": 5
  }
}
```

`type` may also be `identity_assertion` with `"assertion_type":
"verified_email"` — the profile names this ceremony one way in its decision
tree and the other in its metadata, so both spellings work here and mean the
same thing. `anonymous` does not exist here, and an ID-JAG asked for by name is
refused rather than quietly downgraded: verifying one would need a trust list
of issuers, and there is none. JSON or form encoding, as everywhere in this
grant.

**Whether an account exists for that address is not something this endpoint
will tell you.** If nobody holds it here, the request simply stays pending
until it expires. Do not read anything into the silence.

### 2. Show the person the code

As with the device grant, and to the address you named — that is the point of
naming it. They sign in, and the screen tells them who asked and what for. If
they are signed in as somebody else it says so and refuses; approval from any
other account is impossible, not merely discouraged.

### 3. Poll with the claim grant

```http
POST /v1/auth/device/token
Content-Type: application/json

{"grant_type": "urn:workos:agent-auth:grant-type:claim",
 "claim_token": "claim_…"}
```

Same endpoint as the device grant, same five-second interval, same error codes.
Success looks the same too, with one addition:

```json
{
  "access_token": "ann_…",
  "token_type": "Bearer",
  "scope": "send",
  "key_id": "…",
  "prefix": "ann_7e74bc",
  "verified_email": "someone@example.com"
}
```

`verified_email` is the address that approved, and it is the address you asked
for, because no other one could.

### 4. When the code expires but the ceremony has not

Two clocks: the code lives ten minutes, the `claim_token` an hour. A person who
did not get to it in time is normal, and you do not start again — the `400`
carries `error: expired_token` and a `reissue_uri`:

```http
POST /v1/agent/identity/claim
Content-Type: application/json

{"claim_token": "claim_…", "email": "someone@example.com"}
```

`200` with a fresh `user_code` and window. Show that one. The address cannot be
changed: `email`, if you send it, must match what the ceremony was opened with,
so a claim token cannot be walked onto somebody who never agreed to anything.
When the hour is up, `expired_token` comes back without a `reissue_uri`, and
that one is final.

## Using the credential

```http
POST /v1/emails
Authorization: Bearer ann_…
Content-Type: application/json

{"from": "billing@example.com", "to": ["someone@example.net"],
 "subject": "…", "text": "…"}
```

- `401` — no credential, or one we do not recognise. Do not retry with guesses:
  a key is 24 random bytes and only its hash is stored.
- `403` — the credential is real but the scope is too narrow, the domain is not
  verified, or the account is suspended. The `detail` says which, and a scope
  refusal also carries
  `WWW-Authenticate: Bearer error="insufficient_scope", scope="full"`, which
  tells you what to ask for next time rather than only that this failed.
- `429` — `Retry-After` in seconds. 10 requests a second per account, burst 30,
  plus your plan's daily and monthly caps.
- Every failure is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)
  problem document; a validation failure adds an `errors` object keyed by
  field, so you need not parse the prose.

Call from a server. CORS is granted to the dashboard's origin alone, so a key
cannot be used from a page in a browser — and should not be: it is a bearer
secret with no user interaction behind it.

**Over A2A**, likewise: `POST https://mail.misralo.com/a2a`, protocol 1.0 over
HTTP+JSON, card at
[/.well-known/agent-card.json](https://misralo.com/.well-known/agent-card.json).
The same nine skills, the same scope gating, and no model behind it — send a
data part naming a skill and its arguments.

**Over MCP**, the same key is the credential: `POST https://mail.misralo.com/mcp`
with `Authorization: Bearer ann_…`, Streamable HTTP, protocol revision
2026-07-28. The card is at
[/.well-known/mcp/server-card.json](https://misralo.com/.well-known/mcp/server-card.json).
The tool set follows the key's scope, and MCP grants nothing the key does not
already grant — there is no tool for issuing keys, deleting a domain, billing
or closing the account.

## Before you can send: a domain a human must publish

Announcer sends from *your* domain and DKIM-signs every message, so it will not
send from a domain nobody has proven. With a `full` credential:

```http
POST /v1/domains          {"domain": "example.com"}   → returns a `dns` array
POST /v1/domains/{id}/verify                          → checks what is published
GET  /v1/domains/{id}/dns                             → the record again
```

Between the first and the second, somebody has to publish the returned record
in the domain's zone. Verification resolves `<selector>._domainkey.<domain>`
and compares it to the key we issued; it is recorded only on a real match, and
until it matches every send is refused with a `403`. Unless you hold DNS
credentials for the domain, this step belongs to the person you act for — and
it is the one human step the device grant does not remove. An unverifiable
sender is the thing this product exists to prevent.

`POST /v1/domains` is limited to 1 a minute, burst 3: each call generates an
RSA key.

## Revoking

- `GET /v1/keys` lists every key with its `prefix`, `scope`, `created_at`,
  `last_used_at` and `revoked_at`. Never the key itself.
- `DELETE /v1/keys/{id}` revokes one, effective on the next request.
- Rotate by issuing the successor first and revoking the predecessor after it
  works.
- Revocation needs `full` scope or a browser session: a `send` key cannot
  revoke itself. If you hold only a send key and it leaks, you must tell the
  account holder — so do not be the only one who knows the key exists.

The person can always revoke you from the dashboard without asking you first.
Handle a sudden `401` by stopping and saying so, not by trying to re-register.

## Registering with no human at all

Take this path only if nobody can approve a device grant, and only if you
control a mailbox you can read. It creates a whole account, so do not take it
on behalf of somebody who already has one — that is a second tenant with its
own domains, its own quota and its own invoice. Four steps, in order.

### 1. Create the account

```http
POST /v1/auth/signup
Content-Type: application/json

{"email": "you@example.com", "password": "…"}
```

`201` with `{"email": …, "verificationRequired": true}`. The account lands on
the free plan — no card, 100 messages a day, two sending domains.

`409` means the address already has an account. That is not retryable: do not
try variations of the address, and do not create a second account.

**The password is the user's to choose, not yours to generate**, unless the
account is yours alone and nobody else will ever sign into it. It must be
10–128 characters, and it is the credential that can close the account and
change the billing, so do not invent one on somebody's behalf and do not log
it.

### 2. Verify the address

Signup sends one message to that mailbox containing a link of the form
`https://announcer.misralo.com/verify?token=verify_…`, good for 24 hours. Take
the token out of the link and post it:

```http
POST /v1/auth/verify
Content-Type: application/json

{"token": "verify_…"}
```

`200` with `{"verified": true}`. Until this succeeds the account exists and can
do nothing at all: every authenticated route answers `403` with *"Verify your
email address first"*. If you cannot read the mailbox, you cannot finish, and
you should have used the device grant.

### 3. Sign in

```http
POST /v1/auth/login
Content-Type: application/json

{"email": "you@example.com", "password": "…"}
```

`200`, with `Set-Cookie: announcer_session=sess_…` — `HttpOnly`,
`SameSite=Lax`, good for 30 days. Send it back as a **cookie**; the API reads
the session from the `Cookie` header and never as a bearer token. A wrong
address and a wrong password give the same `401`, in the same time, so probing
tells you nothing.

`GET /v1/auth/me` tells you whose session you are holding — address, whether it
is verified, and the account it belongs to — which is how you check you are
acting for who you think you are. `POST /v1/auth/logout` ends it early. Do that
once you have the key in step 4: the session is the credential that can close
the account, and the key is the one you actually need.

### 4. Issue the key

```http
POST /v1/keys
Cookie: announcer_session=sess_…
Content-Type: application/json

{"name": "my-agent", "scope": "send"}
```

`201` with `{"id", "name", "prefix", "scope", "key"}`. **`key` is returned
exactly once** — only its hash is stored — so persist it before doing anything
else. The session acts with `full` scope once verified, so you need no existing
key to mint the first one.

From here the credential is used exactly as the device grant's is: send it as
`Authorization: Bearer ann_…`, keep the scope as narrow as the job allows, and
revoke it with `DELETE /v1/keys/{id}`. See [Using the credential](#using-the-credential)
and [Revoking](#revoking) above — there is one kind of key here, however it was
obtained.

All three `/v1/auth/*` endpoints above are rate limited per client IP: 6 a
minute, burst 12, answering `429` with `Retry-After`. Device polling has its
own, larger budget, so an agent polling does not starve a person signing up
from the same address.

## Stability

`POST /v1/auth/device` and `POST /v1/auth/device/token` are a client contract
and are in [openapi.json](https://misralo.com/openapi.json). The rest of
`/v1/auth/*` — signup, verify, login, and the approval endpoints the dashboard
calls — is the dashboard's own surface, is deliberately not in the description,
and is not versioned: the shapes above are what they do today. Everything you
integrate against afterwards — sending, domains, keys, webhooks — is described,
and that is the surface that will not move under you.

Questions a document cannot answer: hello@misralo.com. Abuse, including an
agent misusing a key: abuse@misralo.com.
