cli auth.md
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 |
POST |
/auth/cli/device/approve |
Approve CLI Sign-In |
POST |
/auth/cli/device/code |
Start CLI Sign-In |
POST |
/auth/cli/device/deny |
Deny CLI Sign-In |
POST |
/auth/cli/device/token |
Poll CLI Sign-In |
Preview CLI Sign-In
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 |
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.
Example
curl "https://api.machinemail.dev/auth/cli/device?user_code=string" \
-H "Cookie: mm_session=$SESSION"
{
"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
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.
Example
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"
}'
{
"status": "approved",
"key_name": "string"
}
Start CLI Sign-In
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 |
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= |
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
curl -X POST "https://api.machinemail.dev/auth/cli/device/code" \
--data-urlencode "client_id=my-client-id"
{
"device_code": "string",
"user_code": "string",
"verification_uri": "https://example.com",
"verification_uri_complete": "string",
"expires_in": 1,
"interval": 1
}
Deny CLI Sign-In
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.
Example
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"
}'
{
"status": "approved",
"key_name": "string"
}
Poll CLI Sign-In
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 |
organization |
Organization |
required | The organization the key belongs to. |
user |
User |
required | The person who approved the sign-in. |
organization is a Organization:
| 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:
| 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
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"
{
"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"
}
}