Authentication
One header authenticates every call: Authorization: Bearer <credential>. For anything automated, that credential should be a personal access token.
Mint a personal access token
- Open dashboard.lithora.app/settings and select the API Tokens tab.
- Click Generate new token.
- Give it a name that identifies where it will live —
ci-release-bot,laptop-cli. You will read this name months from now when deciding what is safe to revoke. - Tick only the scopes the integration actually needs, and set an expiry. The default is 90 days.
- Copy the secret immediately.It is shown once and never again — only a SHA-256 hash is stored server-side, so there is no “show token” button and support cannot recover it for you.
A token is a password with your name on it
Token format
Personal access tokens are prefixed lth_pat_ followed by 32 bytes of URL-safe randomness, e.g. lth_pat_Ab3dEf9hJk2LmN4pQr6StU8vWx0YzA1bCd3EfG5hIj7. The prefix is stable and safe to match on if you run secret scanning across your own repositories — add lth_pat_ to your detection rules.
Send it
Put the token in the Authorization header. Nothing else is required — no API key header, no signature, no session cookie.
export LITHORA_TOKEN=lth_pat_your_token_here
curl -s https://api.lithora.app/api/auth/me \
-H "Authorization: Bearer $LITHORA_TOKEN"A 200 returns your user object. A 401 means the token is missing, malformed, expired or revoked — those four cases are deliberately indistinguishable from outside.
curl -s -X POST https://api.lithora.app/api/tasks \
-H "Authorization: Bearer $LITHORA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Ship the API docs",
"project_id": "8f1c0c2e-4a2d-4f5b-9c31-6b2a7e0d4411",
"priority": "high"
}'import os
import requests
BASE = "https://api.lithora.app"
SESSION = requests.Session()
SESSION.headers["Authorization"] = f"Bearer {os.environ['LITHORA_TOKEN']}"
me = SESSION.get(f"{BASE}/api/auth/me", timeout=30)
me.raise_for_status()
print(me.json()["email"])For anything beyond a one-off call, use the Python SDK — it handles the header, maps status codes to typed exceptions, and exposes the endpoints as methods.
Scopes
Every token carries at least one scope; a token with none is rejected at creation. Grant the narrowest set that does the job.
| Scope | Label in the UI | Grants |
|---|---|---|
| tasks:read | View work items | Read tasks and their subtask trees. |
| tasks:write | Create & update work items | Create, update, reassign and delete tasks. Enforced. |
| projects:read | View projects | Read projects and their metadata. |
| projects:write | Create & update projects | Create, update and delete projects. Enforced. |
| teams:read | View teams & members | Read teams and membership — needed to resolve a team_id. |
| automations:read | View automations & runs | Read automation definitions, versions and run history. |
| automations:write | Create & update automations | Create, toggle, execute and roll back automations. |
| graph:read | Read the work graph | Read work items, relations and cycle-time analytics. |
| ai:chat | Use the AI workspace (chat) | Open sessions and send messages to the agent. |
| ai:confirm | Confirm AI-proposed actions | Approve a staged agent plan. Grant this only if the token should be able to authorise writes the agent proposed. |
| admin:* | Full account access (admin) | Wildcard. Satisfies every scope check. Avoid unless you genuinely need it. |
Scope enforcement is being rolled out route by route
These endpoints check scopes today and return 403 with “This access token is missing the required scope” when the token lacks one:
- POST /api/projects — projects:write
- PUT, DELETE /api/projects/{project_id} — projects:write
- POST /api/tasks — tasks:write
- PUT, PATCH, DELETE /api/tasks/{task_id} — tasks:write
Routes that are not yet wired inherit the owning user's full permissions. Until the rollout completes, treat every token as capable of anything you can do, and rely on expiry and revocation rather than scopes as your primary containment.
Manage tokens over the API
The token endpoints exist, with one deliberate restriction: a token cannot manage tokens. Creating and revoking both return 403 when the caller authenticated with a PAT, so a stolen token cannot mint a never-expiring replacement or delete your other tokens to hide the compromise. Use a browser session or a login JWT for these calls. Minting is also blocked while an admin is impersonating you.
Create
# Requires an interactive session cookie or a JWT — a PAT cannot mint a PAT.
curl -s -X POST https://api.lithora.app/api/auth/tokens \
-H "Authorization: Bearer $LITHORA_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "ci-release-bot",
"scopes": ["tasks:read", "tasks:write", "projects:read"],
"expires_in_days": 90
}'The response is the only time token appears:
{
"token_id": "pat_9f2c1b7a4d3e6510",
"name": "ci-release-bot",
"scopes": ["tasks:read", "tasks:write", "projects:read"],
"prefix": "lth_pat_Ab3dEf9h…",
"token_prefix": "lth_pat_Ab3dEf9h…",
"created_at": "2026-08-01T09:14:22.481Z",
"expires_at": "2026-10-30T09:14:22.481Z",
"last_used_at": null,
"revoked": false,
"status": "active",
"token": "lth_pat_Ab3dEf9hJk2LmN4pQr6StU8vWx0YzA1bCd3EfG5hIj7"
}expires_in_days accepts 0–3650; 0 or null means it never expires, which we discourage. You may hold 50 active tokens; past that, creation returns 400 and you must revoke something first.
List
curl -s https://api.lithora.app/api/auth/tokens \
-H "Authorization: Bearer $LITHORA_JWT"Returns { "tokens": [ ... ] }, masked. Each entry carries a derived status of active, expired or revoked, plus last_used_at — the fastest way to find a token nothing is using any more. GET /api/auth/tokens/scopes returns the grantable scope catalogue as { key, label } pairs.
Revoke
curl -s -X DELETE https://api.lithora.app/api/auth/tokens/pat_9f2c1b7a4d3e6510 \
-H "Authorization: Bearer $LITHORA_JWT"Immediate and irreversible. Pass the token_id, not the secret. Anything still using that token starts failing with 401 on its next request.
The JWT alternative
POST /api/auth/login returns a short-lived JWT for the same Authorization: Bearer header. It is the right credential for an interactive session and the wrong one for automation.
curl -s -X POST https://api.lithora.app/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"your-password"}'Login does not always return a token. If the account has 2FA enabled, or the request comes from a device we have not seen before, you get a challenge instead and must complete it before a token is issued. Client code has to check for the absence of token rather than assuming it is there:
// 2FA enabled on the account — no token issued:
{ "requires_2fa": true, "requires_login_otp": false, "email": "you@example.com" }
// Untrusted device — a one-time code was emailed:
{ "requires_2fa": false, "requires_login_otp": true, "email": "you@example.com" }This is why automation wants a PAT
There is no POST /api/auth/register. Direct registration was removed on purpose; accounts are created only through the email-OTP signup flow in the web app. If you are looking at an older client that calls it, that call has been failing.
Troubleshooting
| You see | Most likely cause |
|---|---|
| 401 | The header is missing the literal word Bearer, the shell did not expand $LITHORA_TOKEN, the token expired, or it was revoked. Also fires if the owning account was suspended or deprovisioned — deactivation closes the PAT door too. |
| 403 | Either the token is missing a required scope (the message names it), you are not a member of the owning team, you hold a read-only guest seat, or you tried to manage tokens with a token. |
| 404 | The id is wrong, or the resource is in the trash. Trashed items are invisible to the API. |
| 400 | On token creation: an unrecognised scope name, an empty scope list, or the 50-token limit. |