API reference

Device login

This is a human authentication bootstrap protocol outside the bearer resource API. It exchanges explicit browser approval for one normal API key. It is a JSON API-key adaptation of the device authorization pattern, not an OAuth token endpoint or a claim of OAuth wire compliance. There is no client secret, registration, refresh token, access_token or grant_type.

Transport and request rules

Use the canonical HTTPS application origin, with no redirects. Only explicitly configured loopback HTTP is accepted in development. All four endpoints below accept POST with Content-Type: application/json, no query parameters, and exactly the listed fields. Objects are required; unknown fields, arrays, null, invalid UTF-8 and wrong field types are refused. Each body is limited to 4,096 streamed bytes and five seconds from application route entry. This is not a deadline for upstream header receipt, proxy buffering or cold startup.

Start and poll need no API key or session and ignore ambient cookies and Authorization. If Origin is supplied it must exactly match the trusted application origin. The two browser endpoints require the signed-in session and that exact Origin, plus the one-use challenge for a decision. Missing or invalid sessions retain the normal 401 refusal and revoked reason; suspension or lost authority may return 403. No CORS access is granted.

Every response uses Cache-Control: no-store, Pragma: no-cache and a server-generated X-Request-ID. Error JSON contains matching request_id. Error bodies contain error and code, optional positive integer retry_after on retryable responses, and interval only for slow_down. Bootstrap failures do not use a bearer challenge. OPTIONS returns bodyless 204 and Allow: POST, OPTIONS; all other methods return 405 with that Allow value, and HEAD is bodyless. There are no /api/v1 or /api/hv aliases.

1. Start

POST /api/auth/device/start
Content-Type: application/json

{"device_name":"Work laptop","scope":"account"}

// Or request precisely one workspace by name:
{"device_name":"Work laptop","scope":"workspace","workspace_name":"Research"}

device_name is trimmed, 1–60 characters, with no controls. Oversized input is refused. It is untrusted requester text. Workspace scope requires a valid workspace_name of at most 40 characters; account scope forbids it. No account ID, user ID, requested role, redirect URI, password or other key may be supplied.

A successful start returns 200 with every field below and allocates no API key:

{
  "device_code": "<32 random bytes, base64url; secret>",
  "user_code": "ABCD-2345",
  "verification_uri": "https://app.example/device",
  "verification_uri_complete": "https://app.example/device?user_code=ABCD-2345",
  "expires_in": 600,
  "interval": 5
}

The display code has eight uniform characters from ABCDEFGHJKLMNPQRSTUVWXYZ23456789. ASCII lowercase is accepted, with either no hyphen or a single hyphen after four characters; whitespace and Unicode lookalikes are refused. The device secret must stay on the initiating device. Never put it in a URL, browser storage, logs, analytics, or model output. Display only the short user code and trusted verification URL.

2. Review in the browser

Open verification_uri_complete or manually enter the code at /device. Sign in through the ordinary login page if required. Compare the code with the initiating terminal. The page shows the current account, current role, device-supplied name, expiry and either the whole account or the exact workspace. Navigation, lookup and closing the page never approve anything.

POST /api/account/device/lookup
{"user_code":"ABCD-2345"}

// 200 response; every field is required:
{
  "user_code":"ABCD-2345", "device_name":"Work laptop",
  "expires_at":"<UTC ISO timestamp>",
  "account":{"id":"acc-…","name":null}, "role":"viewer",
  "scope":{"type":"account"}, "key_name":"Work laptop",
  "approval_token":"<short-lived secret challenge>"
}

POST /api/account/device/decision
{"approval_token":"<challenge from lookup>","decision":"approve"}
// Or: decision: "deny". Response: {"status":"approved"} or {"status":"denied"}.

Both browser calls require JSON and the signed session. The approval_token is a secret synchronizer challenge, bound to the exact displayed request, scope, account, original session and revision. It is single-use and valid no later than the request expiry or original signed session deadline. A fresh lookup supersedes the old challenge. Approve and deny accept no account, scope or name fields. The browser receives no API key or device secret.

Viewer, member and owner can approve, matching ordinary key issuance. The resulting key inherits the holder’s current and future role, restricted to the shown scope. Missing, ambiguous, renamed, deleted or retargeted workspaces are refused without broadening access. Switch accounts using the dashboard navigation and perform a fresh review. Suspended users and accounts are refused.

Approval records permission, and the page says “Approved — finish signing in on your device.” No key exists yet. While it is unconsumed, only the original approving session and account can look it up again and deny with a fresh challenge. Another session cannot inspect or rebind an approved grant. After collection, revoke the key in Settings.

3. Poll or cancel

POST /api/auth/device/poll
Content-Type: application/json

{"device_code":"<secret from start>","action":"poll"}

// Cancel instead of collecting a credential:
{"device_code":"<secret from start>","action":"cancel"}

Wait at least five seconds after start and between polls. Pending returns 202 with exactly {"status":"pending","interval":5} (interval may grow). An early poll adds five seconds to the enforced interval, up to 600, and returns 429 slow_down with interval and positive Retry-After. Honor both for subsequent polls; retries never extend the original 600-second lifetime. Use bounded backoff on connection failure and stop at the original expiry. Cancellation bypasses poll pacing and returns 200 {"status":"cancelled"} even on a repeat retained cancellation.

The first approved poll validates the original approval proof, session epoch and signed deadline, current user/account status, membership and exact workspace. It then mints the ordinary key and marks the grant consumed in one transaction. Concurrent polls can deliver only one key. The account key ceiling is checked at this point; an approval does not reserve a slot.

{
  "status": "authorized",
  "api_key": "<normal API key; shown only once>",
  "key": {"id":"key-…","name":"Work laptop","created_at":"<UTC ISO timestamp>"},
  "account": {"id":"acc-…","name":null},
  "scope": {"type":"account"}
}

// Workspace scope replaces the scope object with:
{"type":"workspace","workspace_id":"wsp-…","workspace_name":"Research"}

Save api_key securely without printing it. Ordinary calls then use the existing bearer resource API. Revocation has its existing typed unauthorized behavior; this grant cannot recreate a revoked key. Browser logout or later password changes have only existing API-key semantics once the key has been issued.

Failures and recovery

HTTPCodeMeaning
400invalid_requestMalformed JSON, media type, UTF-8, unknown fields, wrong field types or invalid syntax.
400invalid_device_codeUnknown device secret. Stop this exchange.
400expired_tokenThe exchange expired. Stop; a new login needs user action.
403access_deniedDenied, or recorded approval lost authority. Stop.
400cancelledPolling a cancelled exchange. Stop.
409already_consumedThe key was already issued. It cannot be collected again.
400key_limitThe account already has 1,000 keys at issuance. Review Settings, then start a new login.
429slow_downUse the returned interval for every later poll, and honor Retry-After.
429rate_limitedSource or review attempt limit. Honor Retry-After.
413invalid_requestMore than 4,096 streamed body bytes.
408request_timeoutThe body did not finish within five seconds after application route entry.
503temporarily_unavailableAdmission or issuance unavailable. Honor Retry-After when present.

Unknown and expired browser lookup codes return a uniform 400 without request details. Challenge/session/account mismatches return generic 403. Terminal records may be retained for at most ten minutes after original expiry and can be evicted earlier; after that, an old secret is simply invalid.

A connection may fail after the key was committed and consumed, or local saving may fail. The server cannot resend that secret or safely mint an automatic replacement. Check the device-named key in Settings, revoke it if needed, and start a new login explicitly. Cancellation after consumption cannot revoke a delivered key. Do not automatically restart login on an ordinary bearer error.

Admission is bounded to five live requests per source and 10,000 globally, with a 20,000-row table ceiling. Expired records are pruned and terminal records may be evicted. Start, lookup and invalid-secret attempts use separate persistent limits, including short-code and signed-user tracks, with a hard 50,000-row device-throttle ceiling. Missing source addresses share a bounded bucket; source accuracy depends on the trusted edge’s address stamping.

Device login protocol