Authentication & Authorization
Start here: README · Related: Conventions & errors · Text translation · Documents · Tenants & billing
In a hurry? The credential every integration needs is a tenant API key. Create one with
POST /api/auth/signup/emailand readtenant_api_keyout of the response — it is returned exactly once. Everything else on this page is detail you can come back for.
TrueIdiom accepts three kinds of credential — a platform operator API key, a per-tenant tenant API key, and OAuth2 bearer tokens issued to individual users — and the auth requirement differs per endpoint. This page is the authoritative description of which credential each auth endpoint needs, how to obtain a bearer token (including the MFA challenge round-trip and the Google sign-in pair), and how roles and tenant scoping constrain what a token can read.
Read it before integrating anything: auth is enforced by FastAPI dependencies rather than declared as OpenAPI security schemes, so the auto-generated /docs page shows every endpoint as if it were public. It is not.
Base URL: https://trueidiom.com. All API routes are prefixed /api. There is no /v1 prefix — the contract is selected by an optional api-version=2026-09-01 query parameter instead (Versioning).
Auth methods at a glance
| Method | Credential | Sent as | Scope | Obtained from |
|---|---|---|---|---|
| Operator API key | The platform break-glass key | X-API-Key: <key> or Authorization: Bearer <key> |
Cross-tenant (platform operator) | Server configuration; not issued over the API |
| Tenant API key | Per-tenant key | X-API-Key: <key> or Authorization: Bearer <key>, optionally with X-Tenant-ID |
One tenant | tenant_api_key in the POST /api/auth/signup/email or OAuth callback response |
| User access token | OAuth2 bearer token | Authorization: Bearer <access_token> |
One user, inside one tenant | POST /api/oauth2/token |
| Page session cookie | Same opaque access token, in a cookie | Cookie: ll_session=<access_token> |
One user, inside one tenant | Set automatically by the endpoints that mint tokens |
Choosing an auth method
- Server-to-server translation integration — use the tenant API key. It is the credential the translation, document, terminology, TM, and storage endpoints resolve a tenant from, it does not expire, and it needs no interactive flow. Capture
tenant_api_keyfrom the signup or OAuth-callback response; it is returned exactly once, at tenant creation. - An application acting on behalf of a signed-in person — use the OAuth2 password grant at
POST /api/oauth2/tokenand send the resultingaccess_tokenas a bearer token. This is the only method that carries a user identity, and therefore the only one that populatesGET /api/auth/me, server-derived reviewer identity on document reviews, and per-user role checks. It takes the user's email and password;tenant_idis an optional pin, not a prerequisite. Handle the two responses that are not tokens: the MFA branch — any tenant may require a second factor of its own members, so a password grant can return a challenge on a deployment where the platform-wide requirement is off ((d) MFA) — and, when you send notenant_idand the credentials are valid in several workspaces, a request to name one (Workspace selection). - Browser front-ends — the same token endpoints also set the
ll_sessioncookie, which is what gates the HTML application pages. You get it for free; no extra call. - Platform operations and support tooling — use the operator API key. It is the only credential that reads across tenants.
Do not use the operator API key for ordinary integration traffic. It is a break-glass credential with no tenant scoping, and endpoints that need to know which tenant a request belongs to (e.g. GET /api/auth/users) require you to name the tenant explicitly when you authenticate with it.
(a) The operator API key
The operator key is a server-side setting, unset by default. It is not issued, rotated, or introspectable through the API.
Headers accepted. The key resolver checks, in order:
X-API-Key: <key>Authorization: Bearer <key>
The first non-empty match wins. Because the second form is shared with user access tokens, a request carrying Authorization: Bearer is tested against the operator key first on operator-aware endpoints; if it is not the operator key it is then resolved as a user token. Prefer X-API-Key to keep the two unambiguous.
The comparison is constant-time. A wrong key is rejected the same way a missing one is.
Cross-tenant reach. The operator key widens tenant-scoped reads. It is not quite the only thing that does: the platform-operator check also passes a signed-in session belonging to a designated operator (an allowlisted email in the one designated operator workspace) while that user has an authenticator enrolled — see (i) Platform operators. The global_administrator role does not grant cross-tenant access, however many admins hold it — see Roles.
Which endpoints accept it.
| Guard | Endpoints |
|---|---|
The admin gate (operator key or an authenticated global_administrator) |
POST /api/oauth2/revoke, POST /api/oauth2/introspect, GET /api/auth/users, DELETE /api/auth/users/{user_id}, POST /api/auth/users/{user_id}/restore, PATCH /api/auth/users/{user_id}/role, POST /api/auth/users/{user_id}/mfa/reset, POST /api/auth/users/{user_id}/password-reset-link, plus the /api/tenants/* administration, usage, invite, and security-policy routes (tenant provisioning, POST /api/tenants, additionally needs the platform-operator check) |
| The platform-operator check (operator key or a designated operator session — see (i)) | Cross-tenant aggregations, e.g. the tenant and stage-timing breakdowns on the document summary endpoint, which return 403 cross-tenant breakdowns require operator access for an authenticated non-operator; and tenant provisioning, POST /api/tenants, which returns 403 tenant provisioning is operator-only to a tenant admin |
| Tenant-identity resolution | Translation, document, terminology, TM, storage, and billing routes. A tenant API key authenticates here; the operator key does not resolve to a tenant on its own, but it does unlock cross-tenant visibility on reads that have already resolved one |
GET /api/auth/me, POST /api/auth/signout, POST /api/oauth2/token, the user-facing MFA endpoints (/api/auth/mfa/enroll, /confirm, /verify, /recovery/regenerate), POST /api/auth/invites/preview, POST /api/auth/password-reset, and the OAuth authorize/callback/client-config endpoints do not accept the operator key. They are user-credential endpoints — the last three are public, and their own input (a challenge id, an invite token, a reset token) is the proof. The two administrative MFA routes — POST /api/auth/users/{user_id}/mfa/reset and PATCH /api/tenants/security — do accept it, and are the only MFA-related endpoints that do.
Failure mode. Endpoints behind the admin gate return 401 {"detail":"admin credentials required"} when no usable credential is present, and 403 {"detail":"admin role required"} when the caller authenticated successfully as a non-admin user.
One anonymous path exists, and it is a fresh-install hole. When no operator key is configured and the tenant store holds no accounts, the admin gate passes outright with no credential at all — open dev mode, so a fresh checkout is usable before anything exists. It closes as soon as either half changes: set an operator key, or create the first tenant, and admin credentials are required from that point on. A deployment must therefore configure its operator key before first use. On a configured deployment — including https://trueidiom.com — the admin endpoints require the operator key or a global_administrator session.
curl -sS "https://trueidiom.com/api/auth/users?tenant_id=7c1f5b0e4a9d4d2f&api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY"
Tenant API keys
A tenant API key is minted once, at tenant creation, and returned as tenant_api_key by POST /api/auth/signup/email and by POST /api/oauth2/{provider}/callback when that callback created the tenant (it is null when the user joined an existing tenant). Only a SHA-256 hash and the first 8 characters (api_key_prefix on the TenantAccount object) are retained server-side — the full key cannot be re-read.
Send it exactly like the operator key (X-API-Key or Authorization: Bearer). Add X-Tenant-ID: <tenant_id> to pin the lookup to a specific tenant; without it the key is matched against all active tenants. Endpoints that require tenant identity and receive neither a valid tenant key nor a valid user bearer token return 401 {"detail":"missing or invalid tenant credentials"}.
(b) The OAuth2 token endpoint
POST /api/oauth2/token
Issues user access/refresh token pairs. Also the endpoint that completes an MFA challenge and the endpoint that rotates refresh tokens.
Auth required: none — this is where credentials are exchanged.
Request encoding: application/x-www-form-urlencoded. JSON bodies are rejected with 422.
Rate limited: yes, on the password and MFA grants. The MFA grant also spends the challenge's and the user's attempt budgets.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
grant_type |
string | Yes | password, urn:ietf:params:oauth:grant-type:mfa-otp, or refresh_token. Whitespace-trimmed and lowercased before matching |
tenant_id |
string | No | password grant only, and optional there: it pins the sign-in to one workspace. Omit it and the server resolves the workspace from the email and password; when those are valid in more than one, the response asks you to choose — see Workspace selection |
username |
string | password only |
User's email address |
password |
string | password only |
User's password |
refresh_token |
string | refresh_token only |
Refresh token from a previous response |
challenge_id |
string | MFA grant only | challenge_id from the MFA challenge |
mfa_code |
string | MFA grant only | Current TOTP code or a single-use recovery code (xxxxx-xxxxx) |
scope |
string | No | Recorded on the issued tokens. On the password and MFA grants it defaults to translate:jobs; on refresh_token, omitting it keeps the scope recorded on the consumed refresh token |
Grant: password
curl -sS -X POST "https://trueidiom.com/api/oauth2/token?api-version=2026-09-01" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=password \
-d username=maria@acme-legal.com \
-d 'password=correct-horse-battery-staple' \
-d scope=translate:jobs
import requests
resp = requests.post(
"https://trueidiom.com/api/oauth2/token?api-version=2026-09-01",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "password",
"username": "maria@acme-legal.com",
"password": "correct-horse-battery-staple",
"scope": "translate:jobs",
},
timeout=30,
)
resp.raise_for_status()
tokens = resp.json()
print(tokens["access_token"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/oauth2/token?api-version=2026-09-01", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "password",
username: "maria@acme-legal.com",
password: "correct-horse-battery-staple",
scope: "translate:jobs",
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const tokens = await res.json();
console.log(tokens.access_token);
The email and password are the whole credential — there is no tenant id to look up first. Add -d tenant_id=7c1f5b0e4a9d4d2f to pin the grant to one workspace; an integration that already sends it keeps working exactly as before, and a pinned grant is never asked to choose a workspace.
When MFA is not required of this user — neither the platform-wide requirement (off by default) nor their tenant's mfa_required, and they are not a designated platform operator — the response is an OAuth2TokenResponse:
{
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "agent_id_developer"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
}
| Field | Type | Description |
|---|---|---|
access_token |
string | Opaque bearer token. Not a JWT — it carries no claims and cannot be parsed client-side |
token_type |
string | Always bearer |
expires_in |
integer | Access-token lifetime in seconds (default 3600) |
scope |
string | Scope recorded on the token |
user |
object | AuthenticatedUser: user_id, tenant_id, email, provider (email or oauth2), role |
refresh_token |
string | Opaque refresh token |
refresh_expires_in |
integer | Refresh-token lifetime in seconds (default 2592000 — 30 days) |
user.roleis not the authority on the caller's role. On thepassword, MFA, andrefresh_tokengrants the returneduserobject is built from the authentication step and carries the model defaultagent_id_developer, while the role stamped onto the token record itself is read from the user record at issuance. CallGET /api/auth/me(orPOST /api/oauth2/introspect) to read the effective role. Tokens issued throughPOST /api/oauth2/{provider}/callbackdo carry the resolved role inuser.role.
A successful password grant also sets the ll_session cookie on the response.
Workspace selection
An email address is unique within a workspace, not across the platform, so the same address can hold accounts in several. When you send no tenant_id and the same email and password verify in more than one of them, the grant answers 200 with no tokens, no challenge, and no cookie:
{
"tenant_selection_required": true,
"tenants": [
{"tenant_id": "7c1f5b0e4a9d4d2f", "tenant_name": "Acme Legal"},
{"tenant_id": "3b8e5d1a7c04f299", "tenant_name": "Acme Labs"}
],
"next": {"oauth2_token": "/api/oauth2/token"}
}
Repeat the grant with tenant_id set to the chosen workspace. That second attempt is an ordinary pinned sign-in and returns tokens — or an MFA challenge, if the chosen workspace requires one. Nothing has been issued in the meantime, so treat this response as a prompt rather than a session, and branch on tenant_selection_required before parsing an OAuth2TokenResponse.
Returning workspace names is safe because the password has already proved the caller owns every account in the list — this response can only reach someone who could sign into all of them. A grant that carries tenant_id never produces this shape, and neither does one whose credentials match a single workspace.
At most ten accounts are checked. Signup verifies no address, so anyone can register any email in a workspace of their own. An unpinned grant therefore checks the password against at most the ten oldest active password accounts registered to the address. When more exist and none of those ten matched, the answer is not invalid credentials but a distinct 401 whose detail begins with a stable token you can branch on:
{"detail": "workspace_id_required: this email belongs to too many workspaces to find yours from the password alone — sign in with your workspace ID"}
Repeat the grant with tenant_id: a pinned grant checks exactly one account, however many the address has elsewhere. A workspace past those ten is reachable only that way — it is never offered in the list above. Every unpinned grant that hits the bound is recorded as login_candidates_capped, whatever its outcome. The workspace ID is account.id in the signup response and user.tenant_id on GET /api/auth/me; the sign-in page's optional Workspace ID field sends it as tenant_id.
What response time can reveal. An unpinned grant checks the password against each password account registered to the address, so it takes longer when there are several. An address with no account costs the same as an address with one, so timing does not reveal whether an address is registered at all. It can reveal that an address is held in more than one workspace — and someone who registers the address in a workspace of their own can learn that way whether it is also held elsewhere, though never in which workspaces. A pinned grant costs one check whatever the address.
Grant: urn:ietf:params:oauth:grant-type:mfa-otp
Used to complete a challenge returned by the password grant. See MFA.
curl -sS -X POST "https://trueidiom.com/api/oauth2/token?api-version=2026-09-01" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=urn:ietf:params:oauth:grant-type:mfa-otp \
-d challenge_id=Qb7xK2mR9tL4pS0nB8yD1wH6 \
-d mfa_code=418205
Returns the same OAuth2TokenResponse shape and sets the session cookie.
Grant: refresh_token
curl -sS -X POST "https://trueidiom.com/api/oauth2/token?api-version=2026-09-01" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=refresh_token \
-d refresh_token=Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK
import os
import requests
resp = requests.post(
"https://trueidiom.com/api/oauth2/token?api-version=2026-09-01",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "refresh_token",
"refresh_token": os.environ["TRUEIDIOM_REFRESH_TOKEN"],
},
timeout=30,
)
resp.raise_for_status()
tokens = resp.json()
print(tokens["access_token"])
print(tokens["refresh_token"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/oauth2/token?api-version=2026-09-01", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: process.env.TRUEIDIOM_REFRESH_TOKEN,
}),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const tokens = await res.json();
console.log(tokens.access_token);
console.log(tokens.refresh_token);
Refresh is rotating and single-use: the presented refresh token is consumed, and a brand-new access/refresh pair is returned. Store the new refresh_token from every response — replaying the old one returns 401. The new pair carries the scope sent with this request, or — when it is omitted or empty — the scope recorded on the consumed refresh token, so a client using another scope need not resend it on every refresh. The session cookie is refreshed too.
Errors
| Status | Detail | Cause |
|---|---|---|
400 |
username and password are required |
password grant missing a field |
400 |
invalid email address |
username is not a parseable email address |
400 |
challenge_id and mfa_code are required |
MFA grant missing a field |
400 |
refresh_token is required |
refresh_token grant missing the token |
400 |
unsupported grant_type |
grant_type is not one of the three above |
401 |
invalid tenant credentials |
password grant with tenant_id: unknown tenant, unknown/inactive user, or wrong password |
401 |
invalid credentials |
password grant without tenant_id: no workspace matched this email and password. One answer for every cause, so the response never reveals where the address is registered |
401 |
workspace_id_required: this email belongs to too many workspaces to find yours from the password alone — sign in with your workspace ID |
password grant without tenant_id: more than ten password accounts are registered to the address and none of the ten oldest matched. Repeat with tenant_id — see Workspace selection |
401 |
invalid or expired MFA challenge |
Challenge unknown, consumed, or expired, or a wrong code — or the challenge's attempt budget is spent, which invalidates it; start the sign-in again |
401 |
invalid or expired refresh token |
Refresh token unknown, already used, revoked, or expired |
403 |
password sign-in is disabled — use single sign-on |
Email/password sign-in is switched off on this deployment (password grant only) |
429 |
too many requests — slow down and retry later |
Rate limit; includes a Retry-After header |
429 |
too many MFA attempts — wait a few minutes, then sign in again |
MFA grant: the user's attempt budget is spent; Retry-After: 900 |
422 |
validation error array | grant_type absent or the body was not form-encoded |
(c) Using bearer tokens and the session cookie
Bearer tokens
Send the access token on every subsequent call:
curl -sS "https://trueidiom.com/api/auth/me?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
The token is opaque and server-validated on each request: it is hashed, looked up, and checked for revocation and expiry. An expired, revoked, or unknown token is indistinguishable in the response from a malformed one.
Optionally add X-Tenant-ID: <tenant_id> on tenant-scoped endpoints. When present, the token's tenant must match it; a mismatch fails the resolution rather than silently using the token's own tenant.
Session cookie
Browser panes cannot set request headers on plain navigations, so the same opaque access token is also written to a cookie.
| Property | Value |
|---|---|
| Name | ll_session |
| Value | The issued access token, verbatim |
Max-Age |
The access-token lifetime (default 3600) |
HttpOnly |
true |
SameSite |
Lax |
Secure |
true in production |
Path |
/ |
Set by: POST /api/oauth2/token (all three grants, on success), POST /api/auth/mfa/verify, POST /api/auth/mfa/confirm (only when a challenge_id was supplied), POST /api/auth/signup/email (only when mfa_required is false), POST /api/oauth2/{provider}/callback (only when mfa_required is false), and POST /api/auth/password-reset (only on the non-MFA branch — a reset that answers with a challenge sets no cookie).
Cleared by: POST /api/auth/signout and POST /api/oauth2/revoke.
Where the cookie is accepted as a credential. The shared session resolver reads the Authorization: Bearer header or the cookie, so both work on admin-guarded endpoints (GET /api/auth/users, PATCH /api/auth/users/{user_id}/role, POST /api/oauth2/revoke, POST /api/oauth2/introspect), for server-derived reviewer identity, and on GET /api/translations/{job_id}/golden, whose download links are plain browser navigations. On DELETE /api/translations/{job_id} and DELETE /api/documents/{document_id} the job's owner is checked against that same bearer or cookie principal — X-Tenant-ID is not consulted there. A few endpoints deviate:
GET /api/auth/mereads the bearer header only. A cookie-only client gets401.POST /api/auth/mfa/enrollandPOST /api/auth/mfa/confirmread the cookie only (or a pendingchallenge_id). A bearer-only API client cannot enrol TOTP for an already-signed-in user without one of those two.POST /api/auth/mfa/recovery/regeneratereads the cookie only — a pendingchallenge_idis refused there on purpose, since that caller has cleared only the password factor.POST /api/auth/signouttakes the access token to revoke from the cookie only; anAuthorizationheader is ignored there.
(d) MFA
MFA is off by default. When it is on, sign-in becomes a two-step exchange: the credential step returns a challenge instead of tokens, and the challenge is completed by presenting a code (POST /api/oauth2/token MFA grant, or POST /api/auth/mfa/verify) or, for a user who has no authenticator yet, by enrolling one (POST /api/auth/mfa/enroll then /confirm).
TOTP is the only method a deployment must support. Required MFA needs no mailer: a user with no authenticator is handed an enrolment challenge and binds one from inside the sign-in, so there is never a code waiting to be delivered.
Who is required to do it
Two switches, combined — plus one rule that overrides both:
| Switch | Scope | Set by |
|---|---|---|
| Platform-wide MFA requirement | Every tenant | Deployment configuration |
mfa_required |
One tenant | PATCH /api/tenants/security |
mfa_required_for_sso |
One tenant | PATCH /api/tenants/security |
| Platform-operator designation | Named individuals, inside the one designated operator workspace only | Deployment configuration |
A password sign-in is challenged when the platform-wide requirement or the tenant's mfa_required is on.
A designated platform operator is always challenged, whatever the other switches say and whichever path they sign in through — the mandatory second factor is a condition of holding cross-tenant reach, not a tenant's preference. See (i) Platform operators.
Completed IdP sign-ins are exempt. A sign-in finished through POST /api/oauth2/{provider}/callback (provider: "oauth2" — Google) is not challenged, even under a platform-wide requirement, unless the tenant sets mfa_required_for_sso. The reasoning: the IdP enforced its own factor policy — conditional access, passkeys, hardware keys — before this service ever saw the user, so a second local TOTP prompt costs sign-in friction and buys no additional assurance. In procurement language: MFA is enforced at the IdP. A tenant that wants the local prompt stacked on top anyway opts in per tenant.
mfa_required_for_sso extends a requirement rather than creating one. With no base requirement in force (neither the platform-wide requirement nor mfa_required) there is nothing to extend, and setting it alone changes nothing.
Neither flag can be self-assigned: mfa_ is a protected tenant-metadata prefix, so metadata supplied to POST /api/auth/signup/email or an OAuth callback is stripped of both keys (case-insensitively) before the tenant is created. PATCH /api/tenants/security is the only writer.
The challenge object
Returned as mfa_challenge by POST /api/auth/signup/email, POST /api/oauth2/{provider}/callback, and POST /api/auth/password-reset, and inline by the password grant.
| Field | Type | Description |
|---|---|---|
challenge_id |
string | Opaque id; supply it to complete the challenge |
method |
string | totp (user has a confirmed secret) or totp_enroll (user has none and must bind one). These are the only two values a production deployment ever returns — a third, email_otp, exists solely behind an off-by-default MFA dev-mode switch that also hands the code back in the response body, and is for local development only |
purpose |
string | login, signup, oauth_login, or password_reset |
expires_at |
string (ISO 8601) | Expiry, 300 seconds after creation by default |
A challenge is single-use: completing it consumes it. Expired or consumed challenges yield 401 invalid or expired MFA challenge. A wrong code does not consume it, but each challenge takes a limited number of attempts — see Attempt budgets.
Branch on method, not on the presence of a challenge. A totp_enroll challenge has no code to verify yet, so POST /api/auth/mfa/verify and the MFA grant can only fail against it — which is why the password grant deliberately omits its grant_type hint for that method and the next object names the enrol/confirm pair instead:
method |
next |
|---|---|
totp |
{"verify_mfa": "/api/auth/mfa/verify", "oauth2_token": "/api/oauth2/token"} |
totp_enroll |
{"mfa_enroll": "/api/auth/mfa/enroll", "mfa_confirm": "/api/auth/mfa/confirm", "oauth2_token": "/api/oauth2/token"} |
Challenge flow during sign-in
POST /api/oauth2/token grant_type=password
│
├── several workspaces ────> { "tenant_selection_required": true, "tenants": [...] }
│ (no tenant_id sent) repeat the grant with tenant_id — that attempt
│ takes one of the branches below
│
├── MFA not required ──────> OAuth2TokenResponse (done)
│ (neither the platform-wide requirement nor the tenant's mfa_required)
│
└── MFA required ──────────> { "mfa_required": true, "mfa_challenge": {...}, "next": {...} }
│
├── method "totp" ────────> read the current code from the authenticator app
│ (+ "grant_type": "urn:ietf:params:oauth:grant-type:mfa-otp")
│ │
│ └──> POST /api/oauth2/token
│ grant_type=urn:ietf:params:oauth:grant-type:mfa-otp
│ challenge_id=…&mfa_code=… (a recovery code also works)
│ → OAuth2TokenResponse
│
└── method "totp_enroll" ─> no authenticator yet — bind one in-flight:
POST /api/auth/mfa/enroll { challenge_id }
POST /api/auth/mfa/confirm { secret, code, challenge_id }
→ { "enrolled": true, "recovery_codes": [...], "token": {...} }
— sign-in complete, ll_session set
The challenged response from the password grant is not an OAuth2TokenResponse — it has no access_token. Branch on the presence of mfa_required before parsing:
{
"mfa_required": true,
"mfa_challenge": {
"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6",
"method": "totp",
"purpose": "login",
"expires_at": "2026-07-26T14:32:05.118422+00:00"
},
"next": {"verify_mfa": "/api/auth/mfa/verify", "oauth2_token": "/api/oauth2/token"},
"grant_type": "urn:ietf:params:oauth:grant-type:mfa-otp"
}
Attempt budgets
Codes — TOTP or recovery codes alike — are counted per challenge and per user, across POST /api/auth/mfa/verify and the urn:ietf:params:oauth:grant-type:mfa-otp grant together:
- Five attempts per challenge. The sixth attempt on a challenge — even with the right code — invalidates it with
401 invalid or expired MFA challenge, and the sign-in starts over from its first step (the password, or the Google sign-in). - Ten wrong codes per user in any 15 minutes, however many challenges they are spread over. Past that, every attempt is refused with
429 {"detail":"too many MFA attempts — wait a few minutes, then sign in again"}andRetry-After: 900until the oldest wrong code ages out of the window. Correct codes do not count.
Both budgets ignore the client IP and apply even on a deployment that switches per-IP rate limiting off, so a stolen password cannot reach the second factor by re-minting challenges or rotating addresses. An invalidated challenge is audited as mfa_challenge_burned, a refusal of the per-user budget as mfa_attempts_exhausted.
TOTP
Enrolment is a two-call handshake. POST /api/auth/mfa/enroll generates a base32 secret and returns it with an otpauth:// provisioning URI and a QR PNG data URL; the secret is not persisted at this point. POST /api/auth/mfa/confirm echoes the secret back with a current code — only a valid code persists the secret to the user record. Codes are checked with one step of tolerance, so the immediately preceding and following 30-second steps are also accepted.
Once a secret is confirmed, every subsequent challenge for that user has method: "totp". Enrolment state (never the secret) is readable as totp_enrolled on GET /api/auth/me for the caller themselves, and on GET /api/auth/users for a tenant admin.
Recovery codes
Confirming an enrolment returns eight single-use recovery codes, formatted xxxxx-xxxxx. Only their SHA-256 hashes are stored, so the response body of POST /api/auth/mfa/confirm (or /mfa/recovery/regenerate) is the one and only time the plaintext exists — a client that drops them leaves the user with no way back in but an admin reset.
- A recovery code is accepted wherever a TOTP code is:
POST /api/auth/mfa/verifyand theurn:ietf:params:oauth:grant-type:mfa-otpgrant. The hyphen makes it unambiguous against a six-digit TOTP code, so one input field can accept either. - Using one consumes it. The same code presented again is rejected exactly like a wrong code (
401). POST /api/auth/mfa/recovery/regeneratereplaces the whole set with eight fresh codes (the old set dies wholesale — that is the point of regenerating). It is session-authorized only: a pendingchallenge_idis deliberately not accepted there, since that caller has cleared just one factor.
Losing the authenticator: admin reset
With the recovery codes gone too, a tenant admin calls POST /api/auth/users/{user_id}/mfa/reset. It clears the member's TOTP secret and recovery codes, so their next sign-in produces a totp_enroll challenge and they bind a new authenticator. Scoping is per-tenant: a user id from another tenant returns 404, and only the operator key crosses tenants. The one account a tenant admin cannot reset is a designated platform operator's, which takes the operator key — see (i) Platform operators.
Audit trail
Every MFA state change is written to the auth audit log, redacted — identifiers and outcomes only, never secrets or codes: mfa_enrollment_challenged, mfa_enrolled, mfa_recovery_codes_regenerated, mfa_recovery_used, mfa_reset_by_admin, mfa_policy_changed, mfa_challenge_burned, mfa_attempts_exhausted, plus the pre-existing mfa_verified and mfa_failed. A read-only PATCH /api/tenants/security (empty body) writes nothing, so the log stays a record of changes.
When no authenticator is enrolled
A user with no confirmed TOTP secret can only be challenged by a flow that offers enrolment. A caller that does not allow enrolment gets 503 service_unavailable ("no authenticator app is enrolled and this deployment cannot deliver email codes") instead of a challenge nobody could complete. Every path that mints a challenge allows enrolment, so an unenrolled user is handed totp_enroll and never reaches that error.
Enrol TOTP for any programmatic integration. It is the only MFA method whose code your client can compute itself, from the secret returned by POST /api/auth/mfa/enroll — bind one in-flight from a totp_enroll challenge (see the flow diagram above).
(e) Roles and what they gate
Two roles exist.
| Value | Display name | Meaning |
|---|---|---|
global_administrator |
Global Administrator | Administrator of one tenant |
agent_id_developer |
Agent ID Developer | Regular user (the default) |
Any other stored value normalizes to agent_id_developer on read, so pre-RBAC user records need no migration.
global_administrator is per-tenant, not cross-tenant
This is the single most common misreading of the model. Despite the name, global_administrator confers no visibility outside the holder's own tenant:
- Every tenant creator is stamped
global_administratorof the tenant they just created —POST /api/auth/signup/emailalways assigns it (unless it is an invite join), andPOST /api/oauth2/{provider}/callbackassigns it when the callback creates the tenant (joiners getagent_id_developer, or the role their invite names). - The platform-operator check — the only thing that widens a tenant-scoped read to cross-tenant — is satisfied by the operator API key, by a designated operator session with an enrolled authenticator (see (i) Platform operators), and by open dev mode. It never consults the role.
- Consequently, a signed-in
global_administratorlisting jobs, documents, usage, or users sees only their own tenant's rows. OnGET /api/auth/usersthe resolved tenant is taken from the principal and a supplied?tenant_idis ignored. OnPATCH /api/auth/users/{user_id}/rolethe lookup is scoped to the principal's tenant, so a user id belonging to another tenant returns404 user not foundrather than leaking that the id exists.
What the role does gate, within the caller's own tenant: the admin-gated endpoints listed under operator API key — token revoke and introspect, user listing and management (revoke, restore, role changes, MFA resets, reset links), tenant administration (security policy, invites) and usage and billing reads, and deleting text and document jobs (DELETE /api/translations/{job_id}, DELETE /api/documents/{document_id}). Reviewing and approving work does not need it — any credential of the tenant can.
How a role is assigned
| Path | Result |
|---|---|
Email signup (POST /api/auth/signup/email) |
Registrant becomes global_administrator of the tenant it just created |
| OAuth callback that creates a tenant | First user becomes global_administrator |
| Invite join, on either route | New user gets the role the invite names |
| OAuth callback joining an existing tenant without an invite | New user becomes agent_id_developer |
PATCH /api/auth/users/{user_id}/role |
Explicit override |
The role is stamped onto access and refresh tokens at issuance, so a promotion takes effect on the holder's next sign-in or refresh, not on their current token. A demotion from global_administrator takes effect at once: it revokes every access and refresh token the user holds, so they are signed out everywhere and sign in again with the new role.
Demoting a tenant's last active global_administrator is refused with 409 to prevent lockout. A revoked admin does not count as active, so they can be demoted without being restored first.
(f) Google sign-in
Google sign-in uses authorization code + PKCE and is off by default. The Google client is a web-application registration whose token endpoint requires the client secret, so the browser POSTs the code (plus state, code_verifier, redirect_uri) to the callback and the server performs the exchange. It is switched on per deployment.
google is the only supported provider path value; it is trimmed and lowercased before matching. When the Google sign-in switch is off, both authorize and callback return 403. A provider value the service does not recognize returns 404 {"detail": "unknown oauth provider"} on both routes — an unrecognized name reaches no generic flow, and is rejected before any state is minted or consumed. A provider the service knows but has switched off is refused with 403 on both routes, likewise before any state is minted or consumed.
Flow
GET /api/oauth2/{provider}/client-config → client_id, endpoints, scope, code_challenge_method
POST /api/oauth2/{provider}/authorize → { state, nonce, expires_at, authorize_url }
↓ browser redirects to the provider with state + nonce + PKCE challenge
↓ provider redirects back to /signin/callback
POST /api/oauth2/{provider}/callback → { account, tenant_api_key, mfa_required, token | mfa_challenge }
or { tenant_selection_required, tenants, next }
→ restart at authorize with the chosen tenant_id
The state is single-use and expires after 600 seconds by default. The nonce is minted server-side, must be echoed to the provider, and is verified against the id_token claims at callback time; it is stripped from the state before any metadata is persisted, so it is never reflected back in tenant metadata.
Tenant resolution at callback
The callback binds the tenant to the verified state. A tenant_id in the callback body is honoured only when it matches the state's tenant_id; a mismatch is 400, and so is a body tenant_id when the state carries none — a workspace can only be pinned at authorize. Resolution order:
- A live invite wins outright. An
invite_tokensupplied atauthorizenames its tenant, which beats a pin, org mapping, existing memberships, and tenant creation — a tenant admin deliberately saying where this person belongs is a stronger signal than any claim the IdP happens to carry. An invite bound to an email address admits only that address: a different Google-verified email gets400 this invite was issued for a different email addressbefore anything is created, and the invite stays usable. The invite is redeemed only when it actually created a membership: an existing member signing in through a link consumes nothing. An unusable invite — expired, revoked, already spent — is ignored, and the sign-in resolves as if there were none. - A pinned
tenant_idon the state signs in an existing member of that workspace. It admits a newcomer only when their verified Workspacehdis the domain that workspace is mapped to (step 3) and itssso_auto_joinis on; anyone else gets403 this organization requires an invitation — ask your workspace admin for an invite link. A workspace ID is not a secret — every invite link carries one — so naming it never creates a membership on its own. Restarting from the workspace chooser is a pinned sign-in. - Org mapping. Google matches on the
hdclaim, present only for Google Workspace accounts — one Workspace domain maps to one TrueIdiom tenant, so the first user from a domain creates it and colleagues join it. Personal Gmail has nohdand is never mapped by email domain. Only the platform writes the mapping: a workspace that a Google Workspace sign-in creates is stamped with its domain, caller-supplied metadata can never set it, and it is never stamped onto an existing workspace; if two workspaces ever carry the same domain, the oldest wins. A workspace founded by email signup is therefore never found by its domain: the first plain Google sign-in from that domain that lands nowhere else founds a new, domain-mapped workspace, and colleagues' Google sign-ins join that one. To use Google with the original workspace, its members sign in through an invite or pinned to its workspace ID, which takes their password accounts over (below). Withsso_auto_joinoff, a new user matching the domain is not admitted. - Existing memberships. Without an invite or a pin, the callback gathers every workspace this Google identity can sign into: those where the address already holds an account — accounts already linked to this Google account first, then oldest — plus the domain's workspace from step 3 when the person is a member there or it is open to auto-join. One candidate: the sign-in lands there. Several: the callback answers with the workspace chooser instead. A person the domain's workspace is closed to who belongs to one other workspace is signed in there rather than refused. A password account that no Google account has ever signed into is never reached by an email match alone — only through an invite, a pin, or the domain mapping, and then the takeover rules below apply. Nothing is stamped onto a workspace reached this way.
- Otherwise a new tenant is created and
tenant_api_keyis returned. That includes someone whose only account for the address is such an unproven password account: a plain Google sign-in gives them a separate new workspace, and their password keeps opening the old one.
A joiner's role is the invite's when there was one, otherwise global_administrator for the person who created the tenant and agent_id_developer for everyone else.
Taking over a password account nobody proved. Email signup verifies no address, so a password account's email is only what someone typed. When a Google sign-in reaches such an account — one no Google account has signed into before — through an invite, a pinned tenant_id (a chooser pick included) or the domain mapping, and Google is authoritative for the address (a gmail.com or googlemail.com address, or any Google Workspace account), the verified Google identity takes the account over. Its password, any pending reset link, its authenticator and its recovery codes are removed; every session and refresh token for it is revoked, and sign-ins it had half-way through MFA are cancelled. The account keeps its id and role, and from then on signs in with Google (GET /api/auth/users lists it as google); where the workspace requires MFA on SSO, the new owner enrols an authenticator of their own. Where Google is not authoritative for the address — a consumer Google account on any other domain — the sign-in is refused with 409 an account with this email already exists in this workspace — sign in with your password, and the account is left untouched. Recorded as google_unproven_account_taken_over or google_unproven_account_takeover_refused.
Google identities are pinned to their sub at their first sign-in: if the account the sign-in resolved to is already linked to a different Google account, the callback fails closed with 400 this email is already linked to a different Google account — contact support rather than re-linking, because mail domains recycle email addresses: when someone leaves and their address is later reissued to a new hire, that new person arrives with the same address but a different Google account, and must not inherit the departed user's workspace membership by signing in with it. Without an invite, a pin, or a domain match, such an account is only a last resort: a person with any other membership signs in there, or gets the chooser. Google id_token verification enforces signature, audience, nonce, and email_verified.
Workspace chooser
When a Google sign-in qualifies for two or more workspaces (step 4 above), the callback answers 200 with no token, no challenge, and no cookie:
{
"tenant_selection_required": true,
"tenants": [
{"tenant_id": "7c1f5b0e4a9d4d2f", "tenant_name": "Acme Legal"},
{"tenant_id": "3b8e5d1a7c04f299", "tenant_name": "Acme Labs"}
],
"next": {"oauth2_authorize": "/api/oauth2/google/authorize"}
}
Restart the flow at POST /api/oauth2/google/authorize with tenant_id set to the chosen workspace; that pinned sign-in returns the ordinary response. As with the password grant's Workspace selection, branch on tenant_selection_required before looking for token. Recorded as google_login_tenant_ambiguous.
Domain auto-join
Org mapping means the second person from a Workspace domain joins the workspace the first one created, with no invitation involved. That is the right default for a company buying one workspace and the wrong one for a company that wants to choose its members, so it is a per-tenant switch: sso_auto_join, set through PATCH /api/tenants/security.
- Absent metadata means on. Every tenant created before the toggle existed keeps the original behaviour; only an explicit
falsecloses the domain. - It gates joining, never signing in. With auto-join off, a new user whose
hdmatches the tenant is not admitted — not by a plain sign-in, and not by one pinned to its workspace ID, so a closed domain cannot be walked around by naming the workspace. A plain sign-in by someone who belongs to other workspaces goes there instead (the chooser when there are several); anyone else gets403 this organization requires an invitation — ask your workspace admin for an invite link, and no user record is created. Existing members keep signing in — one who also belongs to other workspaces is offered the chooser. An admin cannot lock their own company out with this checkbox. - An invite overrides it. Supply
invite_tokenonPOST /api/oauth2/{provider}/authorizeand the sign-in joins the invite's tenant even while the gate is shut — unless the invite is bound to a different email address, which is400 this invite was issued for a different email address, creates nothing, and leaves the invite usable. See (g) Invites. - Refusals are recorded in the audit log as
google_auto_join_refused(the domain matched, auto-join is off) andgoogle_pinned_join_refused(a pinned workspace the user neither belongs to nor matches by domain).
Like the mfa_ flags, sso_auto_join cannot be self-assigned: sso_ is a protected tenant-metadata prefix, so a self-serve signup or OAuth-created tenant can never arrive with its domain pre-opened (or pre-closed) by the caller. The domain mapping itself is protected the same way.
(g) Invites
There is no mailer, so an invite is a link a tenant admin mints and delivers themselves — over Slack, in person, however they like. The admin is the delivery channel, and the link carries the whole invitation.
The token
The plaintext token is "TENANT_ID.SECRET". It is self-locating, so redeeming it costs one account read and no cross-tenant scan, and only its SHA-256 hash is stored.
Treat the token exactly like an access token. Anyone holding it can join the workspace at the role it names. It exists in the response of
POST /api/tenants/invitesand nowhere else — not in the ledger, not in an audit record, not in a log line.GET /api/tenants/invitesdeliberately returns every field except the token, so a leaked admin session cannot recover a link it did not just mint. Do not put it in a URL you log, an analytics event, or a support ticket.
| Property | Value |
|---|---|
| Lifetime | expires_days on the mint call, default 7, clamped to 1–30 (an out-of-range value is clamped, not rejected) |
| Uses | One. Redeeming removes the entry; a replay is 400 invalid or expired invite |
| Pending invites per tenant | 20. Expired entries do not count and are pruned at the next mint; over the cap, minting is 409 |
| Role | Any valid role, default agent_id_developer. An unknown value is 422 — it is never normalized down silently |
| Email binding | Optional. A bound invite refuses any other address with 400, without consuming itself, so a mis-forwarded link does not burn the invitation |
Redeeming one
Two routes accept it, and both join rather than found:
- Password:
POST /api/auth/signup/emailwithinvite_token.tenant_nameandmonthly_char_limitare ignored — the workspace already exists and is not the joiner's to shape. - Federated:
POST /api/oauth2/{provider}/authorizewithinvite_token, then the ordinary callback. The token rides through the IdP round-trip inside the server-held state (the browser never hands it back, which would make it a free choice of tenant) and is popped out before any tenant metadata is written. An email binding is checked against the Google-verified email.
What a join does not do, on either route:
| Founding signup | Invite join | |
|---|---|---|
| Tenant created | Yes | No |
tenant_api_key |
The workspace's key, returned once | null (present-but-null, so clients need not branch on its absence) |
| Free-trial grant | Stamped, subject to the per-IP gate | Neither stamped nor gated — a colleague accepting an invitation is not a new customer, and must not consume the per-IP trial budget |
| Role | global_administrator of the new tenant |
The invite's role |
| MFA | Only the platform-wide requirement can apply (the tenant is seconds old) | The joined tenant's policy applies, so the response may be a challenge |
The invite is claimed just before the membership is created, and put back if creating it then fails — a duplicate email, a rejected password — so a failed registration leaves the link usable. Because the claim is a single step, several people redeeming one link at the same moment cannot all join: exactly one does, and the others get 400 invalid or expired invite. An existing member who signs in through a link consumes nothing.
Preview
POST /api/auth/invites/preview is public and rate-limited (bucket invite-preview), because the person holding the token has no account yet. It exists so a sign-in page can render "you are joining Acme as an administrator".
It is not an oracle. Every failure — malformed, unknown tenant, wrong secret, expired, revoked — is the same 200 {"valid": false}. A 404 for an unknown tenant would let anyone guess ids and learn which workspaces exist; a distinct message would leak which half of a token was wrong. On success it returns the workspace name and the role, and nothing about who the invite was minted for.
Audit trail
invite_created, invite_redeemed, and invite_revoked, each naming the tenant, the acting or joining user, the role, and the invite id — never the token. A bound invite presented at the Google callback with a different verified email is recorded as google_invite_email_mismatch, naming the tenant, that email, and the invite id.
(h) Password reset
With no mailer there is nobody to send a "forgot password" link, so a tenant admin mints one and delivers it out-of-band, exactly as with invites. When a mailer lands, self-service "forgot password" will reuse these same tokens with the app as the sender.
POST /api/auth/users/{user_id}/password-reset-link (admin) returns the link; POST /api/auth/password-reset (public, bucket password-reset) completes it.
| Property | Value |
|---|---|
| Token shape | "USER_ID.SECRET" — self-locating, so the public completion route accepts no email and no tenant from the caller |
| Lifetime | 3600 seconds (expires_in in the mint response) |
| Uses | One per mint. Minting again replaces the outstanding link, which is how a mis-delivered one is killed |
| Applies to | Password members only. An IdP member is 400 user signs in with an identity provider — no password to reset — the user exists, but a reset would grant them nothing |
| Scoping | POST /api/auth/users/{user_id}/mfa/reset's, verbatim: session/bearer admins are confined to their own tenant and a cross-tenant id is 404; only the operator key crosses tenants, and only the operator key may mint a link for a designated platform operator's own account (403 otherwise) |
Completing a reset revokes every other session and refresh token for that account, and cancels any sign-in still waiting on its MFA step. The reset exists because the old credential can no longer be trusted, and neither a 30-day refresh token nor a sign-in the old password started may outlive it. Expect existing clients of that user to start getting 401 immediately.
The two failure modes are deliberately different. A bad link is 401 invalid or expired reset link — one generic answer for malformed, unknown, expired, and already-consumed, so there is no oracle and no confirmation that a user id exists. A link is also honoured only while it could still be minted: if the member has been revoked since, or the account no longer signs in with a password (a Google sign-in took it over), redeeming it is that same 401. A rejected password is 400 naming the rule it broke (the full server policy applies: the minimum length, the common-password list, and the "must not match your email address" rule) and leaves the link usable, since the token was fine.
MFA still applies. An admin-minted link plus a password the holder just chose is still one factor, so completing a reset for a member whose sign-in policy requires MFA returns the same challenge shape as any sign-in rather than tokens:
{
"mfa_required": true,
"mfa_challenge": {"challenge_id": "…", "method": "totp", "purpose": "password_reset", "expires_at": "…"},
"next": {"verify_mfa": "/api/auth/mfa/verify", "oauth2_token": "/api/oauth2/token"}
}
Branch on mfa_required before looking for token. The password change itself has already happened at that point — the challenge gates the session, not the credential write. Without a requirement in force the response is {"token": {...}, "mfa_required": false} and the ll_session cookie is set.
Recorded as password_reset_link_created and password_reset_completed (the latter carrying the number of tokens revoked). Neither record contains link material.
(i) Platform operators
Cross-tenant reach used to mean putting the break-glass operator key into whatever was making the request — and a key in a browser is a key in a history file. A designated operator can hold that reach from an ordinary signed-in session instead.
Designation is deployment configuration only. Operators are named by email address in a deployment setting (matched case-insensitively, whitespace trimmed), together with the one workspace they must belong to. Both halves are required. Signup verifies no address, so anyone can register an operator's email in a workspace of their own; that account is an ordinary member — no operator reach, no forced MFA. A deployment that names no operator workspace designates nobody. No API route grants designation, and none ever will: an endpoint that mints operators is a privilege-escalation surface for any compromised admin session.
Designation alone is not authority. A designated session carries operator reach only while that user has an authenticator enrolled, and both halves are re-checked on every request — nothing is cached. A phished password must never become a cross-tenant credential.
That requirement is enforced from both ends:
- Operator sign-ins are always MFA-challenged, regardless of the platform-wide requirement, of the tenant's own
mfa_required, and of the SSO exemption. Signup is not: a new workspace is never the operator workspace, so designation takes effect only once the deployment names that workspace. From then on the operator's next sign-in gets atotp_enrollchallenge if no authenticator is bound yet, and every sign-in an ordinarytotpchallenge once one is, including through an IdP. - Clearing the enrolment removes the powers immediately, on the very session that had them.
POST /api/auth/users/{user_id}/mfa/resetagainst an operator drops them back to an ordinary tenant admin until they re-enrol; the session token itself stays valid, because an MFA reset revokes nothing. That reset, and a password-reset link for the operator's account, take the operator API key: any session — including another admin of the operator's own workspace — gets403 a platform operator's sign-in can only be reset with the operator API key. Otherwise a co-admin could clear the operator's authenticator, redeem a reset link, and bind an authenticator of their own in its place.
The global_administrator role remains per-tenant and is not a path here: an admin who enrols an authenticator, holds the oldest tenant, and promotes themselves through PATCH /api/auth/users/{user_id}/role still sees exactly one tenant.
The break-glass operator key is unchanged — same header, same power, same standing advice to rotate after use. It remains the credential for non-interactive platform tooling and the way in when no operator can sign in. One asymmetry worth knowing: the key has no "own tenant" to fall back on, so routes like PATCH /api/tenants/security and GET /api/auth/users make a key-only caller name the tenant with ?tenant_id, and answer 400 without it. A designated operator session is not in that position: on both routes it defaults to the workspace it is signed into, and names ?tenant_id only to reach a different one.
Endpoint reference
Every auth response body on error is {"detail": "<message>"} — a 503 service_unavailable, like an unexpected 500 internal_error, also carries error_code and request_id. Request-validation failures (422) return the FastAPI array form: {"detail":[{"loc":[...],"msg":"...","type":"..."}]}. Every response echoes an X-Request-ID header — include it when reporting a problem.
POST /api/auth/signup/email
Create a tenant and register its founding administrator in one call.
Auth required: none. Rate limited: yes (signup bucket).
Gated by: the email-signup switch (on by default). When it is off, returns 403 email signup is disabled — use single sign-on.
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
tenant_name |
string | Yes | Display name of the tenant to create |
email |
string | Yes | Founding admin's email address. Normalized to lowercase |
password |
string | Yes | Minimum 8 characters at the schema level; the server password policy additionally enforces a minimum of 12 characters, rejects common passwords, and rejects a password equal to the email local-part |
monthly_char_limit |
integer ≥ 1 | null | No | Defaults to the server default (500000), which is also the ceiling: a larger value is clamped down to the default rather than rejected. Self-serve signup can lower its own cap as a spend control, never raise it. Ignored when invite_token is present |
metadata |
object | No | Arbitrary key/value metadata stored on the tenant, minus the platform-owned keys listed below. Ignored when invite_token is present |
invite_token |
string | null | No | An invite link's token. Turns this call into a join: no tenant is created, tenant_api_key is null, no free-trial grant is stamped, tenant_name is ignored, and the registrant gets the invite's role. See (g) Invites |
curl -sS -X POST "https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"tenant_name": "Acme Legal",
"email": "maria@acme-legal.com",
"password": "correct-horse-battery-staple",
"monthly_char_limit": 250000,
"metadata": {"industry": "legal"}
}'
import requests
resp = requests.post(
"https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01",
json={
"tenant_name": "Acme Legal",
"email": "maria@acme-legal.com",
"password": "correct-horse-battery-staple",
"monthly_char_limit": 250000,
"metadata": {"industry": "legal"},
},
timeout=60,
)
resp.raise_for_status()
signup = resp.json()
print(signup["account"]["id"])
print(signup["tenant_api_key"])
// Node 18+
const res = await fetch(
"https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tenant_name: "Acme Legal",
email: "maria@acme-legal.com",
password: "correct-horse-battery-staple",
monthly_char_limit: 250000,
metadata: { industry: "legal" },
}),
},
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const signup = await res.json();
console.log(signup.account.id);
console.log(signup.tenant_api_key);
Response 200
{
"account": {
"id": "7c1f5b0e4a9d4d2f",
"name": "Acme Legal",
"api_key_prefix": "kR3vB8xQ",
"monthly_char_limit": 250000,
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"metadata": {"industry": "legal", "trial_chars_granted": 150000}
},
"tenant_api_key": "kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU3iO8rC2bV7x",
"mfa_required": false,
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "agent_id_developer"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
},
"next": {"oauth2_token": "/api/oauth2/token"}
}
tenant_api_key is returned once. Store it before discarding the response.
metadata is stored verbatim except for the platform-owned keys, which are stripped (case-insensitively): anything beginning stripe_, trial_, topup_, mfa_, sso_, invites_, storage_, or an identity provider's organization key such as google. A signup cannot grant itself billing state, pre-set its own sign-in policy, or claim a Google Workspace domain — see (d) MFA and Tenant resolution at callback. The platform adds its own keys instead, such as the free-trial grant trial_chars_granted where a trial is configured (Tenants & billing).
An email or password the policy rejects is refused with 400 before anything is created: no workspace, no API key, and no free-trial grant is spent on a typo.
When MFA is required (only the platform-wide requirement can apply here — the tenant is created by this very call, with no policy of its own yet), token is absent and the response instead carries "mfa_required": true, an mfa_challenge object, and a next object matching the challenge method. The founding admin has no authenticator yet, so in practice the method is totp_enroll and next is {"mfa_enroll": "/api/auth/mfa/enroll", "mfa_confirm": "/api/auth/mfa/confirm", "oauth2_token": "/api/oauth2/token"}: enrol, confirm, and the confirm response completes the sign-in.
The registrant is created with role global_administrator — of their own tenant only.
Joining with an invite
With invite_token set, the response has the same keys but a different meaning: account is the existing workspace (its metadata without the platform-owned keys listed above — a joiner does not see billing state or the invite ledger), tenant_api_key is null, and the registrant holds whatever role the invite named. mfa_required reflects the joined tenant's policy, so unlike a founding signup this call really can return a challenge without a platform-wide requirement being set.
curl -sS -X POST "https://trueidiom.com/api/auth/signup/email?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"tenant_name": "ignored",
"email": "tomas@acme-legal.com",
"password": "correct-horse-battery-staple",
"invite_token": "7c1f5b0e4a9d4d2f.kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU"
}'
An invalid, expired, revoked, or already-redeemed token is 400 invalid or expired invite — as is a link someone else redeemed a moment earlier. A token bound to a different address is 400 this invite was issued for a different email address, and the link stays usable.
This path is gated by the same email-signup switch as the rest of the route: a deployment that turned password signup off does not want invites minting password users either.
Errors
| Status | Cause |
|---|---|
400 |
email already registered for tenant, invalid email address, password must be at least N characters, password is too common; choose a less predictable one, password must not match your email address |
400 |
invalid or expired invite, this invite was issued for a different email address (invite join only) |
403 |
Email signup disabled |
429 |
Rate limited (Retry-After header) |
422 |
Missing required field or password shorter than 8 characters |
POST /api/oauth2/token
See (b) The OAuth2 token endpoint for full parameter, response, and error documentation.
POST /api/oauth2/revoke
Revoke an access or refresh token.
Auth required: admin — the operator API key, or a global_administrator bearer token or session cookie.
Encoding: application/x-www-form-urlencoded.
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The access or refresh token to revoke |
token_type_hint |
string | null | No | Accepted and ignored; both token types are looked up regardless |
curl -sS -X POST "https://trueidiom.com/api/oauth2/revoke?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX
import os
import requests
resp = requests.post(
"https://trueidiom.com/api/oauth2/revoke?api-version=2026-09-01",
headers={
"X-API-Key": os.environ["TRUEIDIOM_OPERATOR_KEY"],
"Content-Type": "application/x-www-form-urlencoded",
},
data={"token": os.environ["TRUEIDIOM_ACCESS_TOKEN"]},
timeout=30,
)
resp.raise_for_status()
print(resp.json()["revoked"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/oauth2/revoke?api-version=2026-09-01", {
method: "POST",
headers: {
"X-API-Key": process.env.TRUEIDIOM_OPERATOR_KEY,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ token: process.env.TRUEIDIOM_ACCESS_TOKEN }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
console.log(body.revoked);
{"revoked": true}
The response is {"revoked": true} whether or not a matching token was found — revocation is idempotent and does not disclose token existence. The call also clears the ll_session cookie on the response, so a browser calling it signs itself out.
Errors: 401 (admin credentials required), 403 (admin role required), 422 (missing token).
POST /api/oauth2/introspect
RFC 7662-style token introspection.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. A user cannot introspect their own token.
Encoding: application/x-www-form-urlencoded.
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | Access or refresh token to inspect |
curl -sS -X POST "https://trueidiom.com/api/oauth2/introspect?api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d token=0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX
{
"active": true,
"token_type": "access_token",
"scope": "translate:jobs",
"exp": 1785076181,
"sub": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"role": "global_administrator"
}
| Field | Type | Description |
|---|---|---|
active |
boolean | Always present. false for unknown, revoked, or expired tokens |
token_type |
access_token | refresh_token | null |
Which store matched |
scope |
string | null | Scope recorded at issuance |
exp |
integer | null | Expiry as a Unix timestamp |
sub |
string | null | User id |
tenant_id |
string | null | Owning tenant |
email |
string | null | User's email |
role |
string | null | Role stamped on the token at issuance |
An inactive token returns "active": false with every other field null. This response — not the caller's role — is the reliable source for a token's role.
Errors: 401, 403, 422 (missing token).
POST /api/auth/signout
Revoke the caller's session token — and its refresh token, when sent — and clear the session cookie.
Auth required: none, by design — it can only invalidate tokens the caller already holds. Safe to call with no active session.
Request body: optional. refresh_token, as an application/x-www-form-urlencoded field or a JSON body {"refresh_token": "..."}.
| Name | Type | Required | Description |
|---|---|---|---|
refresh_token |
string | No | The refresh token from the same sign-in. Revoked along with the session. Holding it is the proof, as with RFC 7009 revocation, so no admin rights are needed |
curl -sS -X POST "https://trueidiom.com/api/auth/signout?api-version=2026-09-01" \
-b "ll_session=0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX" \
-c /dev/null \
-d refresh_token=Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK
{"signed_out": true}
The access token to revoke is read from the cookie only — an Authorization header is ignored here. The cookie holds just the access half of the sign-in's token pair, so send that sign-in's refresh_token too; otherwise the refresh token can keep minting new sessions until it expires. A bearer-token client can revoke its own refresh token this way; its access token then lapses after expires_in seconds, or an admin revokes it through POST /api/oauth2/revoke. The web app sends both.
Errors: none specific to this endpoint — a body that is not a usable refresh token is ignored, never an error.
GET /api/auth/me
Return the signed-in user and their tenant.
Auth required: Authorization: Bearer <access_token>. The session cookie is not accepted here.
curl -sS "https://trueidiom.com/api/auth/me?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
import os
import requests
resp = requests.get(
"https://trueidiom.com/api/auth/me?api-version=2026-09-01",
headers={"Authorization": f"Bearer {os.environ['TRUEIDIOM_ACCESS_TOKEN']}"},
timeout=30,
)
resp.raise_for_status()
me = resp.json()
print(me["user"]["email"])
print(me["user"]["role"])
// Node 18+
const res = await fetch("https://trueidiom.com/api/auth/me?api-version=2026-09-01", {
headers: { Authorization: `Bearer ${process.env.TRUEIDIOM_ACCESS_TOKEN}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const me = await res.json();
console.log(me.user.email);
console.log(me.user.role);
{
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "global_administrator",
"totp_enrolled": true
},
"tenant": {
"id": "7c1f5b0e4a9d4d2f",
"name": "Acme Legal",
"api_key_prefix": "kR3vB8xQ",
"monthly_char_limit": 250000,
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"metadata": {"industry": "legal", "trial_chars_granted": 150000}
}
}
user.provider says how this session was signed in: email for a password sign-in, oauth2 for any identity-provider sign-in (Google reports oauth2, not the provider name) — including a password-registered member who signed in with Google this time. The account's stored label is on GET /api/auth/users. user.role here is read from the token record and is authoritative.
tenant is the full account for a global_administrator (whose token carries that role) — its pending-invite ledger included, without any token material. For every other member, tenant.metadata omits the platform-owned keys (those beginning stripe_, trial_, topup_, mfa_, sso_, invites_, storage_, or an identity provider's organization key such as google): billing state, security policy and the invite ledger are the admins' to read.
user.totp_enrolled is the caller's own MFA state — true once they have confirmed an authenticator. It is enrolment state only; the secret is never exposed. This is the only place a non-admin can read it, since GET /api/auth/users is admin-only.
Errors
| Status | Detail |
|---|---|
401 |
missing bearer token — no Authorization: Bearer header |
401 |
invalid or expired bearer token |
401 |
tenant not found for bearer token — the token is valid but its tenant no longer exists |
GET /api/auth/users
List a tenant's users.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant_id |
query | string | null | Conditional | Required with the operator API key, which has no workspace of its own to fall back on. A designated operator session defaults to the workspace it is signed into and may name another. For a plain session/bearer admin it is ignored — the roster returned is always their own tenant's |
include_revoked |
query | boolean | No | Default false. Revoked members are omitted from the listing unless you ask for them; pass true to see them (each row carries active) so you can offer reinstatement. A seat count taken from the default response therefore covers active members only |
# Session/bearer admin — own tenant, tenant_id not needed
curl -sS "https://trueidiom.com/api/auth/users?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
# Operator key — must name the tenant
curl -sS "https://trueidiom.com/api/auth/users?tenant_id=7c1f5b0e4a9d4d2f&api-version=2026-09-01" \
-H "X-API-Key: $TRUEIDIOM_OPERATOR_KEY"
[
{
"user_id": "9f3a71c4d0b25e88a147c60d",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "global_administrator",
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"totp_enrolled": true
},
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "agent_id_developer",
"active": true,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
]
The projection never exposes password hashes, TOTP secrets, or provider subject identifiers. totp_enrolled is enrolment state — true when the user has a confirmed authenticator — and is what an admin reads before deciding whether a member needs POST /api/auth/users/{user_id}/mfa/reset. Note that provider in this listing is the raw stored label (email, google) — unlike AuthenticatedUser.provider, which collapses federated identities to oauth2.
Errors
| Status | Detail |
|---|---|
400 |
tenant_id query parameter is required with API-key access |
401 |
admin credentials required |
403 |
admin role required |
DELETE /api/auth/users/{user_id}
Revoke a member's access. They are signed out everywhere immediately — live sessions and refresh tokens alike — and can no longer sign in, by password or through an identity provider.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id |
path | string | Yes | Id of the member to revoke |
No request body.
curl -sS -X DELETE "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
Returns the same user projection as GET /api/auth/users, with active now false:
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "agent_id_developer",
"active": false,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
The record is deactivated, not deleted — the tenant's jobs, documents, and audit trail all reference it — so the member disappears from GET /api/auth/users unless you pass ?include_revoked=true, and POST /api/auth/users/{user_id}/restore is the way back. Any outstanding password-reset link for that member is dropped with the same call. Re-revoking an already-revoked member is a no-op that still returns the projection, so a double-clicked button is not an error.
Errors
| Status | Detail |
|---|---|
401 |
admin credentials required |
403 |
admin role required |
404 |
user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
409 |
you cannot revoke your own access, or cannot revoke the last admin of a tenant — the two refusals that stop a workspace losing its last way in |
POST /api/auth/users/{user_id}/restore
Reinstate a revoked member with the role they already held. The only way back in: a user is keyed on tenant and email, so a revoked address that could not be restored could never rejoin its own workspace.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Scoped exactly like the revoke route.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id |
path | string | Yes | Id of the member to reinstate |
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/restore?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
Returns the user projection with active back to true. Nothing is issued here — the member signs in again from scratch, and an authenticator they had enrolled still applies. Restoring an already-active member is a no-op that returns the projection unchanged.
Errors
| Status | Detail |
|---|---|
401 |
admin credentials required |
403 |
admin role required |
404 |
user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
PATCH /api/auth/users/{user_id}/role
Change a user's role.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id |
path | string | Yes | Id of the user to modify |
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
role |
string | Yes | global_administrator or agent_id_developer |
curl -sS -X PATCH "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/role?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX" \
-H "Content-Type: application/json" \
-d '{"role": "global_administrator"}'
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "global_administrator",
"active": true,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
A promotion reaches the member's tokens at their next sign-in or refresh. A demotion from global_administrator revokes every access and refresh token the member holds, so they are signed out everywhere at once and sign in again with the new role.
Errors
| Status | Detail |
|---|---|
400 |
invalid role — value is not one of the two valid roles |
401 |
admin credentials required |
403 |
admin role required |
404 |
user not found — unknown id, or an id in another tenant when called by a session/bearer admin |
409 |
cannot demote the last admin of a tenant — the target is the tenant's last active admin. A revoked admin can be demoted without being restored first |
POST /api/auth/mfa/enroll
Generate a TOTP secret and provisioning material. Does not persist the secret — call /api/auth/mfa/confirm to activate it.
Auth required: either a pending challenge_id (mid-sign-in, before any session exists) or the ll_session cookie. The Authorization: Bearer header is not consulted by this endpoint.
Rate limited: yes (mfa-enroll bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
name |
string | null | No | Overrides the issuer label in the otpauth:// URI. Defaults to TrueIdiom |
challenge_id |
string | null | No | Pending MFA challenge id, proving the password step already passed |
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/enroll?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6"}'
{
"secret": "K5XW6ZDPN5YHI2LPNZSXG43F",
"otpauth_uri": "otpauth://totp/TrueIdiom:maria%40acme-legal.com?secret=K5XW6ZDPN5YHI2LPNZSXG43F&issuer=TrueIdiom",
"qr_code_data_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA…"
}
| Field | Type | Description |
|---|---|---|
secret |
string | Base32 TOTP secret. Hold it client-side and post it back to /confirm |
otpauth_uri |
string | Provisioning URI for authenticator apps |
qr_code_data_url |
string | PNG data URL rendering of otpauth_uri |
Errors
| Status | Detail |
|---|---|
401 |
invalid or expired MFA challenge — a challenge_id was supplied but is unusable: unknown, consumed, or expired, or its user already has an authenticator (replacing one takes a signed-in session) |
401 |
authentication required — no challenge_id and no valid session cookie |
429 |
Rate limited |
POST /api/auth/mfa/confirm
Verify a TOTP code against a freshly generated secret and persist it. When a challenge_id is supplied, a valid code is itself MFA proof and the call completes sign-in.
Auth required: same as /api/auth/mfa/enroll — a pending challenge_id or the ll_session cookie.
Rate limited: yes (mfa-confirm bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
secret |
string | Yes | The secret returned by /api/auth/mfa/enroll |
code |
string | Yes | Current TOTP code. Verified with a ±1 step window |
challenge_id |
string | null | No | Pending challenge id. When present, the challenge is consumed and tokens are issued |
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/confirm?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"secret": "K5XW6ZDPN5YHI2LPNZSXG43F",
"code": "418205",
"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6"
}'
With challenge_id (sign-in completed, ll_session set on the response):
{
"enrolled": true,
"recovery_codes": [
"7f3a1-c04d9", "b2e58-1a7c0", "4f299-6ad31", "57e0b-3c8a2",
"9d1f4-6e0af", "2d75b-18b41", "d7e62-93c04", "a158f-2b7c5"
],
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "email",
"role": "agent_id_developer"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
}
}
Without challenge_id (already-signed-in user adding TOTP) the response carries enrolled and recovery_codes only — no token key, no cookie change. The issued token always uses scope translate:jobs.
| Field | Type | Description |
|---|---|---|
enrolled |
boolean | Always true on 200; a bad code is a 400, not {"enrolled": false} |
recovery_codes |
string[] | Eight single-use codes, xxxxx-xxxxx. Only hashes are stored — this response is the only time the plaintext exists. Show them to the user and let them save them before continuing |
token |
object | OAuth2TokenResponse. Present only when a challenge_id was supplied, meaning this call completed a sign-in |
Re-confirming replaces the authenticator and the recovery set: the codes returned by an earlier confirm stop working.
Errors
| Status | Detail |
|---|---|
400 |
invalid verification code |
401 |
invalid or expired MFA challenge / authentication required |
429 |
Rate limited |
422 |
Missing secret or code |
POST /api/auth/mfa/recovery/regenerate
Issue a fresh set of eight recovery codes, invalidating every previously issued one.
Auth required: the ll_session cookie — a signed-in, enrolled user. A pending challenge_id is not accepted: that caller has cleared only one factor, and minting recovery codes from there would turn a stolen password into a standing second factor.
Rate limited: yes (mfa-recovery bucket).
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/recovery/regenerate?api-version=2026-09-01" \
-b "ll_session=$SESSION"
{
"recovery_codes": [
"0e4a9-d4d2f", "5b0e4-a9d4d", "1f5b0-e4a9d", "c1f5b-0e4a9",
"7c1f5-b0e4a", "b8xQ2-mL7pT", "kR3vB-8xQ2m", "3vB8x-Q2mL7"
]
}
Errors
| Status | Detail |
|---|---|
401 |
authentication required — no valid session cookie |
409 |
no authenticator app is enrolled — nothing to recover into. The request is well-formed; the account state is wrong for it |
429 |
Rate limited |
POST /api/auth/users/{user_id}/mfa/reset
Clear a member's authenticator and recovery codes so they re-enrol at their next sign-in. The answer to a lost phone once the recovery codes are gone too.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id |
path | string | Yes | Id of the member to reset |
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/mfa/reset?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
Returns the same user projection as GET /api/auth/users, with totp_enrolled now false:
{
"user_id": "3b8e5d1a7c04f2996ad3157e",
"email": "tomas@acme-legal.com",
"provider": "google",
"role": "agent_id_developer",
"active": true,
"created_at": "2026-07-26T15:02:18.774591+00:00",
"totp_enrolled": false
}
The member's next sign-in produces a totp_enroll challenge (assuming MFA is still required of them), and confirming it issues a new authenticator and a new recovery set. Recorded as mfa_reset_by_admin in the audit log. A designated platform operator's own account can be reset only with the operator API key — see (i) Platform operators.
Errors
| Status | Detail |
|---|---|
401 |
admin credentials required |
403 |
admin role required |
403 |
a platform operator's sign-in can only be reset with the operator API key — the target is the designated platform operator, and the caller is a session/bearer admin rather than the operator key |
404 |
user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
POST /api/auth/users/{user_id}/password-reset-link
Mint a password-reset link for a member. The answer to a forgotten password in a deployment with no mailer: the admin is the delivery channel.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie. Session/bearer admins are confined to their own tenant; the operator key may target any tenant.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
user_id |
path | string | Yes | Id of the member to mint a link for |
No request body.
curl -sS -X POST "https://trueidiom.com/api/auth/users/3b8e5d1a7c04f2996ad3157e/password-reset-link?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX"
{
"reset_url": "https://trueidiom.com/signin?reset=3b8e5d1a7c04f2996ad3157e.pT0nY4dJ9wZ1sF6gA5eU3iO8",
"token": "3b8e5d1a7c04f2996ad3157e.pT0nY4dJ9wZ1sF6gA5eU3iO8",
"expires_in": 3600
}
| Field | Type | Description |
|---|---|---|
reset_url |
string | Absolute link to hand to the member |
token |
string | The same credential, unwrapped, for a "copy code" affordance |
expires_in |
integer | Seconds until the link expires (3600) |
This is a bearer credential for one account. Deliver it over a channel you trust and never log it. Minting again replaces any outstanding link for that user, so a mis-delivered one is killed by minting another.
Recorded as password_reset_link_created.
Errors
| Status | Detail |
|---|---|
400 |
user signs in with an identity provider — no password to reset |
400 |
this member's access has been revoked — restore it first — the member exists and the admin can see them, but no password will get a revoked account in; POST /api/auth/users/{user_id}/restore comes first |
401 |
admin credentials required |
403 |
admin role required |
403 |
a platform operator's sign-in can only be reset with the operator API key — the target is the designated platform operator, and the caller is a session/bearer admin rather than the operator key |
404 |
user not found — unknown id, or an id in another tenant when called by a session/bearer admin (no existence disclosure) |
POST /api/auth/password-reset
Set a new password from a reset link, and sign the holder in.
Auth required: none — the token is the credential. Rate limited: yes (password-reset bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The token from the mint call. Self-locating, so no email or tenant is accepted (or needed) here |
new_password |
string | Yes | Subject to the full server password policy |
curl -sS -X POST "https://trueidiom.com/api/auth/password-reset?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"token": "3b8e5d1a7c04f2996ad3157e.pT0nY4dJ9wZ1sF6gA5eU3iO8",
"new_password": "correct-horse-battery-staple"
}'
{
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {"user_id": "3b8e5d1a7c04f2996ad3157e", "tenant_id": "7c1f5b0e4a9d4d2f", "email": "tomas@acme-legal.com", "provider": "email", "role": "agent_id_developer"},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
},
"mfa_required": false
}
The ll_session cookie is set alongside. When the member's sign-in policy requires MFA the response carries "mfa_required": true with an mfa_challenge and next instead — and no token key. Branch on mfa_required. See (h) Password reset.
Completing a reset revokes every other access and refresh token for that user, cancels any of their sign-ins still waiting on an MFA step, and consumes the link. Recorded as password_reset_completed with the number of tokens revoked.
Errors
| Status | Detail |
|---|---|
400 |
Password policy rejection (password must be at least N characters, password is too common; choose a less predictable one, password must not match your email address). The link is not consumed — retry with a better password |
401 |
invalid or expired reset link — malformed, unknown, expired, or already consumed; or the member has been revoked since the link was minted, or the account no longer signs in with a password. One generic answer, deliberately |
429 |
Rate limited |
422 |
Missing token or new_password |
POST /api/auth/invites/preview
Report what an invite link opens, for a sign-in page's "you are joining X" banner.
Auth required: none — the token is the credential. Rate limited: yes (invite-preview bucket).
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
token |
string | Yes | The invite token (TENANT_ID.SECRET) |
curl -sS -X POST "https://trueidiom.com/api/auth/invites/preview?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{"token": "7c1f5b0e4a9d4d2f.kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU"}'
{"valid": true, "tenant_name": "Acme Legal", "role": "agent_id_developer"}
Every failure is 200 {"valid": false} — malformed, unknown tenant, wrong secret, expired, revoked. There is no 404 and no distinct message, because this route is public and either would turn it into a tenant-enumeration oracle. The success shape carries the workspace name and role only; the invite's bound email, label, and creator belong to the admin who minted it.
Errors: 429 (rate limited), 422 (missing token).
PATCH /api/tenants/security
Set a tenant's sign-in policy: the two MFA flags and SSO domain auto-join. The only writer of the mfa_ and sso_ tenant-metadata namespaces; see (d) MFA and Domain auto-join for what the flags do.
Auth required: admin — operator API key, or a global_administrator bearer token or session cookie.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
tenant_id |
query | string | null | Conditional | Required with the operator API key, which has no workspace of its own to fall back on and answers 400 without it. A designated operator session defaults to the workspace it is signed into and names ?tenant_id only to reach a different one. A session/bearer tenant admin may omit it (their own tenant is used) or name their own tenant; naming another tenant returns 404 |
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
mfa_required |
boolean | null | No | Every member must present MFA to sign in. null/omitted leaves the current value alone |
mfa_required_for_sso |
boolean | null | No | Extend that requirement to completed IdP sign-ins. null/omitted leaves the current value alone |
sso_auto_join |
boolean | null | No | Allow a new user matching the tenant's Google Workspace domain (hd) to join without an invitation. Absent metadata means true, so only an explicit false closes the domain. null/omitted leaves the current value alone |
Every field is tri-state, so one flag can be changed without restating — and clobbering — the others. An empty body ({}) is a read: it returns the effective policy and writes nothing, not even an audit record.
curl -sS -X PATCH "https://trueidiom.com/api/tenants/security?api-version=2026-09-01" \
-H "Authorization: Bearer 0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX" \
-H "Content-Type: application/json" \
-d '{"mfa_required": true}'
{"mfa_required": true, "mfa_required_for_sso": false, "sso_auto_join": true}
All three keys are returned on every call, including one that patched only an MFA flag. That is deliberate: clients read an absent sso_auto_join as on, so omitting it would render a closed domain's checkbox as open until the page reloaded.
The response is always the resulting policy, read back through the same helpers the next sign-in will evaluate — not an echo of the request. A change is recorded as mfa_policy_changed in the audit log, naming the acting admin (the operator key has no principal to name) and the flags set.
Turning mfa_required on does not sign anyone out: existing tokens remain valid until they expire, and the requirement applies from each member's next sign-in. Members with no authenticator get a totp_enroll challenge then — no mailer, no admin provisioning step.
Errors
| Status | Detail |
|---|---|
400 |
tenant_id query parameter is required with API-key access |
401 |
admin credentials required |
403 |
admin role required |
404 |
tenant not found — unknown id, or another tenant's id from a session/bearer admin |
POST /api/auth/mfa/verify
Complete an MFA challenge and receive tokens. The JSON equivalent of the urn:ietf:params:oauth:grant-type:mfa-otp grant; use whichever encoding suits your client.
Auth required: the challenge_id itself is the credential.
Rate limited: yes (mfa-verify bucket, per client IP and challenge), and every code spends the challenge's and the user's attempt budgets, shared with the MFA grant.
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
challenge_id |
string | Yes | From the mfa_challenge object. A totp_enroll challenge cannot be completed here — enrol instead |
code |
string | Yes | TOTP code or a single-use recovery code (xxxxx-xxxxx) |
scope |
string | No | Default translate:jobs |
curl -sS -X POST "https://trueidiom.com/api/auth/mfa/verify?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "Qb7xK2mR9tL4pS0nB8yD1wH6",
"code": "418205",
"scope": "translate:jobs"
}'
Returns an OAuth2TokenResponse (identical shape to the token endpoint) and sets the ll_session cookie.
Errors
| Status | Detail |
|---|---|
401 |
invalid or expired MFA challenge — unknown, consumed, expired, or wrong code — or the challenge's five attempts are spent, which invalidates it; start the sign-in again |
429 |
too many requests — slow down and retry later — per-IP rate limit; Retry-After header |
429 |
too many MFA attempts — wait a few minutes, then sign in again — the user's budget of wrong codes is spent; Retry-After: 900 |
422 |
Missing challenge_id or code |
POST /api/oauth2/{provider}/authorize
Mint the single-use state (and, for OIDC providers, the nonce) that binds a sign-in attempt.
Auth required: none. Rate limited: yes (oauth-authorize bucket).
Gated by: the Google sign-in switch.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider |
path | string | Yes | google — the only supported value. Trimmed and lowercased; a value the service does not recognize is 404 unknown oauth provider, and a provider that is switched off is 403 |
Request body — application/json
All fields optional; send {} for the common case.
| Name | Type | Required | Description |
|---|---|---|---|
tenant_name |
string | null | No | Name to use if the callback ends up creating a tenant |
tenant_id |
string | null | No | Pin the sign-in to one workspace — what a restart from the workspace chooser sends. It signs in an existing member; a newcomer is admitted only when their verified Workspace hd is the domain mapped to that workspace and its auto-join is on, and anyone else gets 403 at the callback. The callback refuses a body tenant_id that differs from this one |
monthly_char_limit |
integer ≥ 1 | null | No | Applied when the callback creates the tenant, capped at the server default as on email signup |
redirect_uri |
string | null | No | Recorded on the state |
email_hint |
string | null | No | Normalized and pinned on the state |
metadata |
object | No | Merged into tenant metadata if the callback creates a tenant, with the platform-owned keys stripped as on email signup |
invite_token |
string | null | No | An invite link's token. Carried through the IdP round-trip inside the server-held state — the browser never hands it back at the callback, which would make it a free choice of tenant — and popped out before any tenant metadata is written. A live invite decides which tenant the completed sign-in lands in; an unusable one is ignored. An invite bound to an email address is refused at the callback with 400 this invite was issued for a different email address when the Google-verified email differs — without being consumed. See (g) Invites |
curl -sS -X POST "https://trueidiom.com/api/oauth2/google/authorize?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{"tenant_name": "Acme Legal"}'
{
"provider": "google",
"state": "R7xK2mQ9vL4pT0nB8yD1wH6zJ3cF5gA0",
"expires_at": "2026-07-26T14:39:41.221904+00:00",
"authorize_url": "/api/oauth2/google/callback?state=R7xK2mQ9vL4pT0nB8yD1wH6zJ3cF5gA0&email={email}",
"nonce": "8yD1wH6zJ3cF5gA0eU2iO7"
}
| Field | Type | Description |
|---|---|---|
provider |
string | Normalized provider label |
state |
string | Single-use state. Send it to the provider and back to the callback |
expires_at |
string (ISO 8601) | 600 seconds after issue, by default |
authorize_url |
string | Callback path template for this state |
nonce |
string | Present for google. Send as the OIDC nonce; it is verified against the id_token at callback |
Errors: 400 (invalid email address if email_hint is malformed), 403 (Google sign-in is disabled when the switch is off, or any other provider that is switched off), 404 (unknown oauth provider — the path value is not a provider the service recognizes), 429 (rate limited), 422 (malformed body).
POST /api/oauth2/{provider}/callback
Verify the provider's response, resolve or create the tenant and user, and issue tokens.
Auth required: the verified state plus the provider's id_token/code.
Rate limited: yes (oauth-callback bucket).
Gated by: the same flag as authorize.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
provider |
path | string | Yes | google — the only supported value; a value the service does not recognize is 404 unknown oauth provider, and a provider that is switched off is 403. Must match the provider recorded on the state |
Request body — application/json
| Name | Type | Required | Description |
|---|---|---|---|
state |
string | Yes | The state from authorize |
id_token |
string | null | Unless code |
OIDC ID token. Supply this or code |
code |
string | null | Unless id_token |
Authorization code; the server exchanges it |
code_verifier |
string | null | With code |
PKCE verifier for the exchange |
redirect_uri |
string | null | With code |
Redirect URI used in the authorize request |
email |
string | null | No | Ignored — the email comes from the verified token |
tenant_id |
string | null | No | Must match the state's tenant_id; sending one when the state carries none is 400 too. A workspace is pinned at authorize, never here |
tenant_name |
string | null | No | Overrides the state's tenant_name when creating a tenant |
monthly_char_limit |
integer ≥ 1 | null | No | Overrides the state's value when creating a tenant, capped at the server default |
metadata |
object | No | Merged over the state's metadata when creating a tenant, with the platform-owned keys stripped |
# The server performs the code exchange
curl -sS -X POST "https://trueidiom.com/api/oauth2/google/callback?api-version=2026-09-01" \
-H "Content-Type: application/json" \
-d '{
"state": "M4pT0nB8yD1wH6zJ3cF5gA0eU2iO7rY4",
"code": "4/0AVMBsJhq2mL7pT0nY4dJ9wZ1sF6gA5eU3iO8rC2bV7xN0k",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"redirect_uri": "https://trueidiom.com/signin/callback"
}'
Response 200
{
"account": {
"id": "7c1f5b0e4a9d4d2f",
"name": "Acme Legal",
"api_key_prefix": "kR3vB8xQ",
"monthly_char_limit": 500000,
"active": true,
"created_at": "2026-07-26T14:29:41.006318+00:00",
"metadata": {
"google": {"hd": "acme-legal.com"},
"trial_chars_granted": 150000
}
},
"tenant_api_key": "kR3vB8xQ2mL7pT0nY4dJ9wZ1sF6gA5eU3iO8rC2bV7x",
"mfa_required": false,
"token": {
"access_token": "0nQ7cH2rY8pL4vTx1sB6mK9dJ3wZgF5aR0eU7iO2yN4bV8cX",
"token_type": "bearer",
"expires_in": 3600,
"scope": "translate:jobs",
"user": {
"user_id": "9f3a71c4d0b25e88a147c60d",
"tenant_id": "7c1f5b0e4a9d4d2f",
"email": "maria@acme-legal.com",
"provider": "oauth2",
"role": "global_administrator"
},
"refresh_token": "Xk2mQ9vR7tL0pS4nB8yD1wH6zJ3cF5gA0eU2iO7rY4bV9xN6dM1qT8sK",
"refresh_expires_in": 2592000
},
"next": {"oauth2_token": "/api/oauth2/token"}
}
| Field | Type | Description |
|---|---|---|
account |
object | The resolved or created TenantAccount. Only the call that created it returns its metadata whole; any other sign-in gets it without the platform-owned keys (billing state, security policy, the invite ledger) |
tenant_api_key |
string | null | Non-null only when this call created the tenant. Returned once |
mfa_required |
boolean | true when the resolved tenant sets mfa_required_for_sso and a requirement is in force (the platform-wide requirement or the tenant's mfa_required) — or the signed-in user is a designated platform operator, who is always challenged whatever the tenant's policy says. A completed IdP sign-in is otherwise exempt — see (d) MFA |
token |
object | OAuth2TokenResponse. Present only when mfa_required is false; the ll_session cookie is set alongside it |
mfa_challenge |
object | Present only when mfa_required is true, with purpose: "oauth_login" |
next |
object | Follow-up paths: oauth2_token, plus either verify_mfa or the mfa_enroll/mfa_confirm pair, matching the challenge method |
Unlike the token endpoint, token.user.role here reflects the user's resolved role.
When the identity qualifies for two or more workspaces, the 200 is instead the workspace chooser — tenant_selection_required: true, tenants (each tenant_id and tenant_name), and next.oauth2_authorize — with no account, token, challenge, or cookie. Branch on tenant_selection_required first, then restart at authorize with the chosen tenant_id.
Errors
| Status | Detail |
|---|---|
400 |
invalid or expired oauth state |
400 |
oauth provider mismatch — path provider differs from the state's provider |
400 |
oauth state is missing its nonce; restart sign-in |
400 |
code or id_token is required for Google callback |
400 |
google id_token nonce mismatch, google account email is not verified, and other token-verification failures — including a token naming an unknown or missing signing key (invalid google id_token: ...) |
400 |
tenant_id does not match authorized oauth state — the body's tenant_id differs from the state's, or the state carries none |
400 |
this invite was issued for a different email address — the invite named at authorize is bound to another address. Nothing is created, and the invite stays usable |
400 |
invalid or expired invite — the invite was redeemed by someone else, or revoked, while this callback was completing. Restart sign-in: a spent invite is then ignored and the sign-in resolves the ordinary way |
400 |
this email is already linked to a different Google account — contact support |
400 |
access to this workspace has been revoked — the account this sign-in resolved to was revoked by a workspace admin |
403 |
Google sign-in is disabled — the switch is off (any other provider that is switched off is refused the same way) |
403 |
this organization requires an invitation — ask your workspace admin for an invite link — the domain matched a workspace with sso_auto_join off and this user is neither a member there nor carrying an invite (nor, on a plain sign-in, a member of any other workspace), or a pinned tenant_id names a workspace the user does not belong to and whose domain is not theirs |
404 |
unknown oauth provider — the provider path value is not a provider the service recognizes, checked before the state is consumed |
404 |
tenant not found — the pinned tenant_id does not exist |
409 |
an account with this email already exists in this workspace — sign in with your password — the sign-in reached a password account no Google account has signed into, and Google is not authoritative for the address; see takeover |
429 |
Rate limited |
503 |
google sign-in is temporarily unavailable — try again — Google's signing keys could not be fetched. The body also carries error_code service_unavailable and request_id. The state has been used, so restart sign-in |
503 |
google sign-in is not configured on this service — Google sign-in is switched on but the deployment lacks its Google credentials. The body also carries error_code service_unavailable and request_id; retrying will not help until the deployment is fixed |
422 |
Missing state |
GET /api/oauth2/google/client-config
Public client parameters for the Google OIDC flow.
Auth required: none. Gated by: the Google sign-in switch.
curl -sS "https://trueidiom.com/api/oauth2/google/client-config?api-version=2026-09-01"
{
"enabled": true,
"missing_reason": null,
"client_id": "418205773190-k5xw6zdpn5yhi2lpnzsxg43f.apps.googleusercontent.com",
"authorize_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
"scope": "openid email profile",
"response_type": "code",
"code_challenge_method": "S256",
"redirect_path": "/signin/callback"
}
| Field | Type | Description |
|---|---|---|
enabled |
boolean | true only when both a client id and a client secret are configured |
missing_reason |
string | null | Operator-facing hint when enabled is false |
client_id |
string | Public OAuth client id |
authorize_endpoint |
string | https://accounts.google.com/o/oauth2/v2/auth |
scope |
string | openid email profile |
response_type |
string | code |
code_challenge_method |
string | S256 |
redirect_path |
string | Path Google redirects back to |
There is no token_endpoint here by design: the browser never calls it. Post the code to /api/oauth2/google/callback and the server exchanges it with the client secret.
enabled: false with a populated missing_reason is a normal 200 response, not an error — render your sign-in button accordingly.
Errors: 403 Google sign-in is disabled.
Operational notes
Rate limiting
Auth endpoints are rate-limited per client IP and per endpoint bucket. The budget defaults to 30 attempts per 60 seconds, and rate limiting is on by default but can be switched off per deployment. The client IP is the address the service's own ingress recorded — the right-most X-Forwarded-For entry, which that ingress appends. Entries a client puts in the header itself are ignored, so rotating a forged X-Forwarded-For does not reset the budget.
| Bucket | Endpoint |
|---|---|
signup |
POST /api/auth/signup/email |
token-password |
POST /api/oauth2/token, grant_type=password |
token-mfa |
POST /api/oauth2/token, MFA grant (additionally keyed by challenge_id) |
mfa-verify |
POST /api/auth/mfa/verify (additionally keyed by challenge_id) |
mfa-enroll |
POST /api/auth/mfa/enroll |
mfa-confirm |
POST /api/auth/mfa/confirm |
mfa-recovery |
POST /api/auth/mfa/recovery/regenerate |
invite-preview |
POST /api/auth/invites/preview |
password-reset |
POST /api/auth/password-reset |
oauth-authorize |
POST /api/oauth2/{provider}/authorize |
oauth-callback |
POST /api/oauth2/{provider}/callback |
Exceeding a bucket returns 429 {"detail":"too many requests — slow down and retry later"} with a Retry-After header carrying the window length in seconds. Where the deployment keeps its rate-limit counters in a shared store the budget is shared across app instances; otherwise it is per-process.
MFA codes have their own attempt budgets on top of these buckets — per challenge and per user rather than per IP, and in force even where per-IP rate limiting is switched off.
Request size limits
Two size limits apply to request bodies — the first to every request, auth requests included:
| Limit | Value | Behaviour |
|---|---|---|
| Request body | 30000000 bytes (30 MB) |
Enforced globally and streaming, so it also covers chunked bodies. Exceeding it returns 413 {"detail":"request body exceeds MAX_UPLOAD_BYTES"} |
Declared Content-Length |
10000000 bytes |
Checked on POST /api/translations — JSON, raw-body and flatten-to-text submissions; a PDF, DOCX or XLSX upload is held to the 30 MB limit above instead. Exceeding it returns 413 {"detail":"request body exceeds MAX_REQUEST_BYTES"}; a non-numeric Content-Length returns 400 {"detail":"invalid Content-Length header"} |
Translation payloads are additionally bounded to 50000 source characters per text and 50 translations per batch request, both returning 413.
Request correlation
Send X-Request-ID: <id> to correlate your logs with the server's; the value is bound into every server log line for that request. The server echoes it back in the X-Request-ID response header, generating one when you do not supply it, and a JSON error body that carries request_id carries that same id — never null.
Deployment switches that change auth behaviour
These are set per deployment, never through the API. They are listed because each one changes what an integration sees.
| Switch | Default | Effect |
|---|---|---|
| Operator break-glass key | unset | The cross-tenant operator credential. Configured on every production deployment; see (a) |
| Platform-operator designation | none | Emails designated as platform operators, and the one workspace they must belong to — with either unset, nobody is designated. A matching signed-in user in that workspace gets cross-tenant reach only while an authenticator is enrolled, and their sign-ins are always MFA-challenged. Deployment config only — no route grants it; see (i) |
| Email signup | on | Off → POST /api/auth/signup/email returns 403 |
| Email/password sign-in | on | Off → the password grant returns 403 |
| Platform-wide MFA requirement | off | On → signup and the password grant return an MFA challenge instead of tokens, for every tenant. Completed IdP sign-ins stay exempt unless the tenant sets mfa_required_for_sso. A single tenant can require MFA without this via PATCH /api/tenants/security |
| MFA dev mode | off | On → a user with no authenticator may be handed an email_otp challenge whose one-time code is returned in the response body. Local development only: it is off by default, the deployment logs a warning when it is on, and no production deployment ever emits an email_otp challenge |
| Google sign-in | off | Off → the Google authorize/callback/client-config endpoints return 403 |
| Access-token lifetime | 3600 s |
Access-token lifetime and session-cookie Max-Age |
| Refresh-token lifetime | 2592000 s |
Refresh-token lifetime |
| MFA challenge lifetime | 300 s |
How long a challenge stays completable |
| OAuth state lifetime | 600 s |
How long an authorize state stays redeemable |
| Minimum password length | 12 |
Server-side minimum at registration and on password reset |
| Session cookie name | ll_session |
Name of the cookie the token endpoints set |
Session cookie Secure flag |
on in production | Makes the cookie HTTPS-only |
| Auth rate limiting | on | Per-IP auth endpoint rate limiting. The MFA attempt budgets apply even when it is off |
Two further switches gate features you will meet immediately after authenticating, and are documented here because their failure modes look like auth failures:
- Translation (on by default) — when switched off, endpoints that start new translation work return
503 {"detail":"translation is temporarily disabled"}. Reads, reviews, and downloads of existing jobs continue to work. - Billing (off by default) — when off, the
/api/billing/*routes return503 {"detail":"billing is not enabled"}and no subscription gate applies anywhere. When on, endpoints that start billable work return402for tenants without a current subscription (or with an exhausted free-trial character grant), with detail text naming/billing.
What's next
- Conventions & errors — the error catalog every endpoint here can return.
- Text translation — the first thing to call once your credential works.
- Documents — layout-preserving PDF, DOCX and XLSX jobs, on the same credentials.
- Tenants & billing — provisioning tenants and the subscription a credential bills against.