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/email and read tenant_api_key out 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_key from 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/token and send the resulting access_token as a bearer token. This is the only method that carries a user identity, and therefore the only one that populates GET /api/auth/me, server-derived reviewer identity on document reviews, and per-user role checks. It takes the user's email and password; tenant_id is 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 no tenant_id and 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_session cookie, 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:

  1. X-API-Key: <key>
  2. 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.role is not the authority on the caller's role. On the password, MFA, and refresh_token grants the returned user object is built from the authentication step and carries the model default agent_id_developer, while the role stamped onto the token record itself is read from the user record at issuance. Call GET /api/auth/me (or POST /api/oauth2/introspect) to read the effective role. Tokens issued through POST /api/oauth2/{provider}/callback do carry the resolved role in user.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

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.

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/me reads the bearer header only. A cookie-only client gets 401.
  • POST /api/auth/mfa/enroll and POST /api/auth/mfa/confirm read the cookie only (or a pending challenge_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/regenerate reads the cookie only — a pending challenge_id is refused there on purpose, since that caller has cleared only the password factor.
  • POST /api/auth/signout takes the access token to revoke from the cookie only; an Authorization header 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"} and Retry-After: 900 until 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/verify and the urn:ietf:params:oauth:grant-type:mfa-otp grant. 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/regenerate replaces the whole set with eight fresh codes (the old set dies wholesale — that is the point of regenerating). It is session-authorized only: a pending challenge_id is 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_administrator of the tenant they just created — POST /api/auth/signup/email always assigns it (unless it is an invite join), and POST /api/oauth2/{provider}/callback assigns it when the callback creates the tenant (joiners get agent_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_administrator listing jobs, documents, usage, or users sees only their own tenant's rows. On GET /api/auth/users the resolved tenant is taken from the principal and a supplied ?tenant_id is ignored. On PATCH /api/auth/users/{user_id}/role the lookup is scoped to the principal's tenant, so a user id belonging to another tenant returns 404 user not found rather 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:

  1. A live invite wins outright. An invite_token supplied at authorize names 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 gets 400 this invite was issued for a different email address before 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.
  2. A pinned tenant_id on the state signs in an existing member of that workspace. It admits a newcomer only when their verified Workspace hd is the domain that workspace is mapped to (step 3) and its sso_auto_join is on; anyone else gets 403 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.
  3. Org mapping. Google matches on the hd claim, 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 no hd and 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). With sso_auto_join off, a new user matching the domain is not admitted.
  4. 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.
  5. Otherwise a new tenant is created and tenant_api_key is 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 false closes the domain.
  • It gates joining, never signing in. With auto-join off, a new user whose hd matches 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 gets 403 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_token on POST /api/oauth2/{provider}/authorize and 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 is 400 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) and google_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/invites and nowhere else — not in the ledger, not in an audit record, not in a log line. GET /api/tenants/invites deliberately 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/email with invite_token. tenant_name and monthly_char_limit are ignored — the workspace already exists and is not the joiner's to shape.
  • Federated: POST /api/oauth2/{provider}/authorize with invite_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 a totp_enroll challenge if no authenticator is bound yet, and every sign-in an ordinary totp challenge 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/reset against 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 — gets 403 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 return 503 {"detail":"billing is not enabled"} and no subscription gate applies anywhere. When on, endpoints that start billable work return 402 for 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.