## Preview CLI Sign-In

| Method | Path | Summary |
| --- | --- | --- |
| `GET` | `/auth/cli/device` | [Preview CLI Sign-In](/content/docs/reference/api/cli-auth#preview-cli-sign-in/index.html) |
| `POST` | `/auth/cli/device/approve` | [Approve CLI Sign-In](/content/docs/reference/api/cli-auth#approve-cli-sign-in/index.html) |
| `POST` | `/auth/cli/device/code` | [Start CLI Sign-In](/content/docs/reference/api/cli-auth#start-cli-sign-in/index.html) |
| `POST` | `/auth/cli/device/deny` | [Deny CLI Sign-In](/content/docs/reference/api/cli-auth#deny-cli-sign-in/index.html) |
| `POST` | `/auth/cli/device/token` | [Poll CLI Sign-In](/content/docs/reference/api/cli-auth#poll-cli-sign-in/index.html) |

### Preview CLI Sign-In

http

```
GET /auth/cli/device
```

Called by the dashboard `/cli` page for a signed-in human before approving. Returns what the CLI reported (client, hostname; self-reported) and the key approval would create; never the device code. `404` for an unknown, decided or expired code. Dashboard session only: API keys are not accepted. Every call counts against a per-user and per-IP limit on code lookups (brute-force brake): `429` with `Retry-After`.

**Authentication:** the dashboard `mm_session` cookie.

#### Query parameters

| Field | Description |
| --- | --- |
| `user_code` | `string` Required

The code the CLI printed (XXXX-XXXX); case, spaces and dashes are ignored.

#### Response

**200** The pending sign-in (`application/json`)

| Field | Description |
| --- | --- |
| `user_code` | `string` Required

The code, formatted XXXX-XXXX.
| `device_id` | `string` Required

Opaque ID of this sign-in (cld...), not a secret: the device_id of the session.cli.device stream events that report it approved, denied or redeemed.
| `client_id` | `string` Required

Always machinemail-cli.
| `client_name` | `string | null` Required

What the CLI says it is. Self-reported.
| `hostname` | `string | null` Required

The machine the CLI says it runs on. Self-reported.
| `key_name` | `string` Required

Name of the key approval creates: CLI on <hostname>.
| `permissions` | `string[]` Required

Permissions of that key: ["*"] (every permission except inbound_raw).
| `created_at` | `string (date-time)` Required

When the CLI asked for the code.
| `expires_at` | `string (date-time)` Required

When the code expires.

#### Errors

| Status | Meaning |
| --- | --- |
| `400` | Invalid request: error.type is validation and error.param names the field. |
| `401` | Missing, invalid, expired or revoked credential. |
| `429` | Rate limited. |
| `500` | Unexpected server error. |

Error bodies use the [error envelope](/content/docs/reference/errors/index.html).

#### Example

Request

```
curl "https://api.machinemail.dev/auth/cli/device?user_code=string" \
  -H "Cookie: mm_session=$SESSION"
```

Response 200

```
{
  "user_code": "string",
  "device_id": "string",
  "client_id": "my-client-id",
  "client_name": "string",
  "hostname": "string",
  "key_name": "string",
  "permissions": ["string"],
  "created_at": "2026-10-01T12:00:00.000Z",
  "expires_at": "2026-10-01T12:00:00.000Z"
}
```

### Approve CLI Sign-In

http

```
POST /auth/cli/device/approve
```

Approves a pending code for the signed-in user. The CLI’s next poll receives a new API key named `CLI on <hostname>` in the organization the session is signed in to (every permission except `inbound_raw`), listed under API keys and revocable there. Dashboard session only. Every call counts against a per-user and per-IP limit on code lookups (brute-force brake): `429` with `Retry-After`.

A person in several organizations creates the key in another one by switching the session to it first (`POST /auth/organizations/switch`, which applies that organization’s sign-in policy), then approving. `organization_id` names the organization the page showed: `409 conflict` when the session is no longer on it.

**Authentication:** the dashboard `mm_session` cookie.

#### Request body

Content type: `application/json`.

| Field | Description |
| --- | --- |
| `user_code` | `string` Required

The code the CLI printed (XXXX-XXXX); case, spaces and dashes are ignored.
| `organization_id` | `string`

The organization the key is for, as the page showed it: it must be the one the session is signed in to. To create the key in another organization the person belongs to, switch the session to it first (POST /auth/organizations/switch). Default: the session’s organization.

#### Response

**200** Approved (`application/json`)

| Field | Description |
| --- | --- |
| `status` | `"approved" | "denied"` Required
| `key_name` | `string` Required

Name of the key the CLI receives (approved) or would have received (denied).

#### Errors

| Status | Meaning |
| --- | --- |
| `400` | Invalid request: error.type is validation and error.param names the field. |
| `401` | Missing, invalid, expired or revoked credential. |
| `409` | Conflicts with an existing resource. |
| `429` | Rate limited. |
| `500` | Unexpected server error. |

Error bodies use the [error envelope](/content/docs/reference/errors/index.html).

#### Example

Request

```
curl -X POST "https://api.machinemail.dev/auth/cli/device/approve" \
  -H "Cookie: mm_session=$SESSION" \
  -H "Content-Type: application/json" \
  -d '{
  "user_code": "string"
}'
```

Response 200

```
{
  "status": "approved",
  "key_name": "string"
}
```

### Start CLI Sign-In

http

```
POST /auth/cli/device/code
```

RFC 8628 device authorization request, made by `mm login`. Returns a `device_code` for the CLI to poll with and a `user_code` (`XXXX-XXXX`) for the human to approve at `verification_uri` (`https://machinemail.dev/cli`) while signed in to the dashboard. Both expire after 10 minutes.

Form-encoded or JSON body. `client_id` must be `machinemail-cli` (`401 invalid_client` otherwise). Rate limited per client IP: `429 rate_limited` with `Retry-After`.

**Authentication:** None.

#### Request body

Content type: `application/x-www-form-urlencoded` or `application/json`. Required.

| Field | Description |
| --- | --- |
| `client_id` | `string` Required

Always machinemail-cli.
| `client_name` | `string`

Shown to the human on the approval page, for example MachineMail CLI 0.4.0. At most 100 characters. (max length 100)
| `hostname` | `string`

The machine the CLI runs on. Names the key (CLI on <hostname>) and is shown on the approval page. At most 253 characters. (max length 253)

#### Response

**200** Codes issued (`application/json`)

| Headings | Description |
| --- | --- |
| `Cache-Control` | `string` no-store |

|Field|Description|
|---|---|
| `device_code` | `string` Required

Secret the CLI polls the token endpoint with. Never show it.
| `user_code` | `string` Required

Code the human checks on the approval page: 8 letters as XXXX-XXXX (consonants only, no look-alikes).
| `verification_uri` | `string` Required

https://machinemail.dev/cli: where the human approves.
| `verification_uri_complete` | `string` Required

verification_uri with ?code=<user_code>, for opening in a browser.
| `expires_in` | `integer` Required

Seconds until both codes expire (600).
| `interval` | `integer` Required

Minimum seconds between polls of the token endpoint (5).

#### Errors

| Status | Meaning |
| --- | --- |
| `400` | OAuth error invalid_request. |
| `401` | OAuth error invalid_client. |
| `429` | OAuth error rate_limited. |
| `500` | Unexpected server error. |

#### Example

Request

```
curl -X POST "https://api.machinemail.dev/auth/cli/device/code" \
  --data-urlencode "client_id=my-client-id"
```

Response 200

```
{
  "device_code": "string",
  "user_code": "string",
  "verification_uri": "https://example.com",
  "verification_uri_complete": "string",
  "expires_in": 1,
  "interval": 1
}
```

### Deny CLI Sign-In

http

```
POST /auth/cli/device/deny
```

Denies a pending code: the CLI’s next poll gets `access_denied` and no key is created. Dashboard session only. Every call counts against a per-user and per-IP limit on code lookups (brute-force brake): `429` with `Retry-After`.

**Authentication:** the dashboard `mm_session` cookie.

#### Request body

Content type: `application/json`.

| Field | Description |
| --- | --- |
| `user_code` | `string` Required

The code the CLI printed (XXXX-XXXX); case, spaces and dashes are ignored.

#### Response

**200** Denied (`application/json`)

| Field | Description |
| --- | --- |
| `status` | `"approved" | "denied"` Required
| `key_name` | `string` Required

Name of the key the CLI receives (approved) or would have received (denied).

#### Errors

Error bodies use the [error envelope](/content/docs/reference/errors/index.html).

#### Example

Request

```
curl -X POST "https://api.machinemail.dev/auth/cli/device/deny" \
  -H "Cookie: mm_session=$SESSION" \
  -H "Content-Type: application/json" \
  -d '{
  "user_code": "string"
}'
```

Response 200

```
{
  "status": "approved",
  "key_name": "string"
}
```

### Poll CLI Sign-In

http

```
POST /auth/cli/device/token
```

RFC 8628 token request with `grant_type=urn:ietf:params:oauth:grant-type:device_code`. Poll no faster than `interval` seconds until the human decides.

After approval the next poll returns a new organization API key (`CLI on <hostname>`, every permission except `inbound_raw`, attributed to the approving user) as `access_token`, once: the device code is then spent.

Errors (RFC 6749 section 5.2 bodies, status `400`): `authorization_pending` (keep polling), `slow_down` (polled faster than the interval; add 5 seconds), `expired_token` (start again), `access_denied` (denied in the browser), `invalid_grant` (unknown or already used device code), `unsupported_grant_type`, `invalid_request`.

**Authentication:** None.

#### Request body

Content type: `application/x-www-form-urlencoded` or `application/json`. Required.

| Field | Description |
| --- | --- |
| `grant_type` | `string` Required

urn:ietf:params:oauth:grant-type:device_code.
| `device_code` | `string` Required

The device_code from the device authorization response.
| `client_id` | `string` Required

Always machinemail-cli.

#### Response

**200** The new API key, returned once (`application/json`)

| Headings | Description |
| --- | --- |
| `Cache-Control` | `string` no-store |
| `Pragma` | `string` no-cache |

|Field|Description|
|---|---|
| `access_token` | `string` Required

The new API key (mm_live_...). Returned once; send it as Authorization: Bearer.
| `token_type` | `"Bearer"` Required
| `api_key_id` | `string` Required

ID of the key, for DELETE /v0/api-keys/{api_key_id} on logout.
| `key_name` | `string` Required

Name of the key: CLI on <hostname>.
| `organization` | [Organization](/content/docs/reference/api/objects#organization/index.html) Required
| `user` | [User](/content/docs/reference/api/objects#user/index.html) Required

... (additional fields omitted for brevity)

#### Errors

| Status | Meaning |
| --- | --- |
| `400` | OAuth error: authorization_pending, slow_down, expired_token, access_denied, invalid_grant, unsupported_grant_type, invalid_request. |
| `500` | Unexpected server error. |

#### Example

Request

```
curl -X POST "https://api.machinemail.dev/auth/cli/device/token" \
  --data-urlencode "grant_type=string" \
  --data-urlencode "device_code=string" \
  --data-urlencode "client_id=my-client-id"
```

Response 200

```
{
  "access_token": "string",
  "token_type": "Bearer",
  "api_key_id": "key_01k6x3e8mr0000h4r0ty6c9k1n",
  "key_name": "string",
  "organization": {
    "organization_id": "org_01k6x2m9qd0000a7h3kq9v2c4e",
    "name": "Production",
    "slug": "string",
    "plan": "free",
    "inbox_count": 1,
    "domain_count": 1,
    "domain_limit": 1,
    "usage": {
      "message_count": 1,
      "storage_bytes": 2048,
      "messages_received_30d": 1,
      "messages_sent_30d": 1
    },
    "shared_domain": "string",
    "undo_send": true,
    "default_inbox_id": "acme@machinemail.dev",
    "updated_at": "2026-10-01T12:00:00.000Z",
    "created_at": "2026-10-01T12:00:00.000Z"
  },
  "user": {
    "user_id": "usr_01k6x3j0rw0000n8w4yc0g3q5s",
    "email": "agent@machinemail.dev",
    "name": "Production",
    "organization_id": "org_01k6x2m9qd0000a7h3kq9v2c4e",
    "role": "owner",
    "created_at": "2026-10-01T12:00:00.000Z"
  }
}
```
