---
title: "CLI Auth"
description: "The device authorization endpoints (RFC 8628) behind CLI sign-in: start a sign-in, approve or deny it in the browser, and poll until the CLI receives its key."
canonical: "https://machinemail.dev/docs/reference/api/cli-auth"
---

# CLI Auth

> The device authorization endpoints (RFC 8628) behind CLI sign-in: start a sign-in, approve or deny it in the browser, and poll until the CLI receives its key.

Source: https://machinemail.dev/docs/reference/api/cli-auth

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

## 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 | Type | | 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 | Type | | 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

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

```json title="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 | Type | | 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 (409 conflict otherwise, for example after another tab switched organizations). 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 | Type | | 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

```bash title="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"
}'
```

```json title="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 | Type | | 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`)

Headers:

| Field | Type | | Description |
| --- | --- | --- | --- |
| `Cache-Control` | `string` |  | no-store |

| Field | Type | | 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

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

```json title="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`.

#### Response

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

#### Errors

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

#### Example

```bash title="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"
}'
```

```json title="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 | Type | | 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`)

Headers:

| Field | Type | | Description |
| --- | --- | --- | --- |
| `Cache-Control` | `string` |  | no-store |
| `Pragma` | `string` |  | no-cache |

| Field | Type | | 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` | required | The organization the key belongs to. |
| `user` | `User` | required | The person who approved the sign-in. |

`organization` is a [Organization](/content/docs/reference/api/objects#organization/index.html):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `organization_id` | `string` | required | ID of the organization. |
| `name` | `string` | required | Name of the organization. |
| `slug` | `string` | required | URL-safe name of the organization, chosen from its name when it is created. It never changes, not even when the organization is renamed. {slug}@ on the shared domain is reserved for this organization: no other organization can create that inbox. |
| `plan` | `"free" \| "pro" \| "team" \| "scale"` | required | Plan of the organization. |
| `inbox_count` | `integer` | required | Number of inboxes in the credential scope. |
| `domain_count` | `integer` | required | Number of domains in the organization. |
| `domain_limit` | `integer` |  | Custom domains included in the plan. The Free plan cannot add more; paid plans can, and each extra domain is billed. Always present on GET /v0/organizations and PATCH /v0/organizations. |
| `usage` | `object` |  | Usage. Always present on GET /v0/organizations and PATCH /v0/organizations. |
| `shared_domain` | `string` |  | Domain every organization can create inboxes on, used when an inbox is created without domain. Always present on GET /v0/organizations and PATCH /v0/organizations. |
| `undo_send` | `boolean` | required | Undo send in the dashboard: when true (the default), a message sent from the MachineMail dashboard waits 5 seconds before it goes out, so it can be undone. Only the dashboard reads it: the API, the MCP server and the CLI always send at once. |
| `default_inbox_id` | `string \| null` | required | The default inbox of the organization (its inbox_id). A new organization gets {slug}@ on the shared domain, named after the organization, as its default inbox; when that address cannot be used (a reserved name), it starts without one. The dashboard lists the default inbox first. Change it with PATCH /v0/organizations. null when there is none: it was cleared, its inbox was deleted, or (for a pod or inbox credential) it is outside the credential scope. |
| `updated_at` | `string (date-time)` | required | Time at which the organization was last updated. |
| `created_at` | `string (date-time)` | required | Time at which the organization was created. |

`user` is a [User](/content/docs/reference/api/objects#user/index.html):

| Field | Type | | Description |
| --- | --- | --- | --- |
| `user_id` | `string` | required | ID of the user. |
| `email` | `string` | required | Email address the user signs in with. |
| `name` | `string \| null` |  | Display name, when set. |
| `organization_id` | `string` | required | ID of the organization this record is about: the session’s active organization, or the organization listed. |
| `role` | `"owner" \| "admin" \| "member"` | required | Role in the organization. Owners and admins invite, change roles and remove members; only owners grant or remove the owner role, and an organization always keeps one owner. |
| `created_at` | `string (date-time)` | required | When the user was created. |

#### 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

```bash title="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"
```

```json title="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"
  }
}
```
