Errors and retries
Every server API failure has a stable code. Read the code, not the message: messages can change, codes do not. A failure is never a verdict, so never treat it as human, and never run or replay the protected action because verification failed.
How to read a failure
- /api/v1 endpoints return {"error": "<code>", "code": "<code>"}, with the same value in both fields. A
bad_request,not_found,conflict,identity_requiredorpayload_too_largefailure also carriesmessage, a sentence saying what to fix, and a 400 that concerns one request field names it infield(for example{"error": "bad_request", "code": "bad_request", "message": "limit must be an integer from 1 to 1000", "field": "limit"}). Log the message and show it to developers; branch only on code. 5xx bodies carry only error and code. - A 400
bad_requestnames the request field it refers to infieldwhenever one field is at fault: a body property (eventId,units,result,approvalToken) or a query parameter (from,to,limit,cursor,view,accountId,series). The same rule holds on every /api/v1 endpoint. - Paged reads (GET /api/v1/decisions.json, /api/v1/agent-usage.json and /api/v1/usage/ledger) answer
page: {limit, total, nextCursor}(the ledger's entries view leaves out total). FollownextCursoruntil it is null; a cursor is bound to the period and filters it was issued for, and any other use is 400 withfieldcursor. - A 401
unauthorizedfrom /api/v1 also carrieshint, which names the credential that was refused, never its value:"server_key"(the Authorization header is missing, does not use the Bearer scheme, or the key is wrong, revoked or past its rotation overlap) or"session_token"(the token is malformed, edited or was not issued by this Telltale server). For example{"error": "unauthorized", "code": "unauthorized", "hint": "server_key"}. A 401 withouthintis stillunauthorized; readhintwhen it is there and fall back to checking both. The /v1 endpoints never add it. - The v1 endpoints of the compatibility API return exactly {"error": "<code>"}.
- 429 responses carry a Retry-After header in seconds. Budgeted responses also carry RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window resets); see Rate limits below.
- The browser SDK's own endpoints (/api/sdk) are handled by the SDK. Your code only sees NotAllowedError (collection is paused until consent, or withdrawn), AbortError (superseded work) and errors whose code is network_unavailable (the Telltale server is unreachable, offline or missed the request deadline; client.status() reports ok, degraded or offline and the last error code). optIn() never rejects for network reasons.
/api/v1 error codes
These apply to POST /api/v1/verify, /api/v1/identify and /api/v1/usage, and to the GET exports.
session_reinit_required has two statuses on /api/v1 because it covers two cases: 401 when the token's session expired, was revoked or was removed, and 409 when POST /api/v1/identify tries to replace an identity already bound to the session. The /v1 compatibility API answers 409 for both (its table is below). The served adapter accepts either status and recovers the same way.
| Status | Code | What it means | What to do | Adapter retries |
|---|---|---|---|---|
| 400 | bad_request | The body is not a JSON object, or a field is missing or invalid: actionId, eventId, units, identity IDs, a forwarded request, or agent-usage from, to, limit or cursor. | Fix the request. Sending it again unchanged fails the same way. | No |
| 401 | unauthorized | The Authorization header is missing or does not use the Bearer scheme (Authorization: Bearer <key>; the word Bearer is case-insensitive, a bare key is refused), the server key is wrong, revoked or past its rotation overlap, or the session token is invalid: not a string of at most 250 characters, malformed, truncated, edited, or not issued by this Telltale server. | hint says which one: server_key means check TELLTALE_SERVER_KEY in your server configuration (publishable keys, ao_pk_, never work here); session_token means replace only the session token with a fresh one from the SDK. Without a hint, check the key first. | No |
| 401 | session_reinit_required | The session token was issued by this Telltale server, but its session expired, was revoked by a consent withdrawal, or was removed. | Recover with a fresh browser session (below). Do not resend the old token. | No |
| 403 | forbidden | The token belongs to a different Site than the server key, or the request came from a browser page on the public collector origin. | Use the server key of the Site that issued the token, and call from your backend only. | No |
| 404 | not_found | Unknown path or method. | Check the path against the API reference. | No |
| 404 | not_found | GET /api/v1/sessions/{sessionId}: the session is not one this server key can read. It is unknown, belongs to another Site, was erased or deleted, or is past retention. The answer is the same in every case. | Check you stored the ID for this Site. A session past retention can't be brought back. | No |
| 409 | conflict | POST /api/v1/usage reused an eventId with different facts (session, action or units). | Do not retry. Use a new eventId only for a genuinely separate piece of work. | No |
| 409 | identity_required | POST /api/v1/usage on a session that has no server-verified identity yet. | Call /v1/identify (or /api/v1/identify) for this session from your backend, then send the same usage request again with the same eventId. Do not mint a new eventId. | No |
| 409 | conflict | POST /api/v1/decisions/{id}/outcome reported a different outcome than the first one, or POST /api/v1/decisions/{id}/review targeted a denied decision. | The first outcome stands. A deny is never loosened by review. | No |
| 409 | approval_invalid | POST /api/v1/approvals/redeem: the token is not an approval for this Site, or the hold was rejected or cancelled. | Do not perform the write. | No |
| 409 | approval_used | The approval was already redeemed for a different request, or more than 60 seconds ago. Approvals are one-time. An identical redeem (same approval, session, action and targetId) within 60 seconds of the first succeeds again with the original answer and replayed: true, so a retry after a dropped connection is safe. | Do not perform the write again: perform it only on replayed: false. | No |
| 409 | approval_expired | More than 15 minutes passed since the approval. | Ask for a new review. | No |
| 409 | step_up_not_held | POST /api/v1/decisions/{decisionId}/step-up: the decision is not held for review (allowed, denied, or a badge check). | Nothing to step up; follow the decision. | No |
| 409 | step_up_mismatch | The step-up names a different session, action or targetId than the held check. | Report with the session, action and target of the held request. | No |
| 409 | step_up_settled | The hold was already settled (by a reviewer, another step-up report, expiry or consent withdrawal). The identical report within 60 seconds gets the original answer instead. | Read the decision (GET /api/v1/decisions/{decisionId}) and follow it. | No |
| 409 | step_up_expired | More than 15 minutes passed since the hold. | Verify again; a new hold can be stepped up. | No |
| 409 | step_up_superseded | A passed step-up was reported after the session gained a verified identity or an automation trace, and the action's AI agent rule denies. Your own check cannot pass a session the rule now denies. | Verify again and follow the new decision. | No |
| 409 | approval_mismatch | The token is for a different session, action or targetId. | Do not perform the write. Redeem with the session, action and target of the held request. | No |
| 409 | session_reinit_required | POST /api/v1/identify tried to replace the identity already bound to this session. | Recover with a fresh browser session for the new user (below). | No |
| 413 | payload_too_large | The body is larger than 131,072 bytes. | Send a smaller body. Verify needs only sessionToken, actionId and an optional forwarded request. | No |
| 429 | rate_limited | The server key used more than its budget (12,000 requests a minute per server key ID by default), or this client address sent more than 1,800 failed authentications in a minute. | Wait for Retry-After before the next call. Never replay the protected action. | Verify, once |
| 500 | internal_error | The Telltale server failed unexpectedly. | Fail closed for the protected action and report it. Retrying the same request rarely helps. | No |
| 503 | service_unavailable | Storage was briefly busy or the database could not be reached. The body never carries the database's own message. | Retry verification with short jitter, within your deadline. | Verify, up to 3 tries |
| other 4xx | request_failed | A client error without a more specific code. | Treat it as a failed request and fix the call. | No |
| 409 | agent_tokens_disabled | POST /api/v1/agent-tokens while agent tokens are off for the Site. | A workspace owner or admin turns them on in Settings, then issue again. | No |
| 409 | tool_not_approved | POST /api/v1/agent-tokens named a tool that is not reviewed and enabled on this Site. | Review and enable the tool first, or leave it out. | No |
| 409 | tool_not_read_only | POST /api/v1/agent-tokens named a tool that changes data. Agent tokens cover read-only tools; writes stay in the page, where the person confirms them. | Leave the tool out. | No |
identify and usage never retry automatically, because a repeated write is not always harmless. Usage is idempotent only for an identical eventId with identical facts, which returns the original record with replayed: true. The eventId is looked up before the session token is checked, so this replay also works after the session expired, was withdrawn or was removed. New work still needs a live session token, so report work within the 24-hour session life.
identify checks the account-traits limit before it binds anything: when POST /api/v1/identify answers 409 conflict for accountTraits, the identity was not bound either, and the request can be sent again without accountTraits.
/v1 error codes (compatibility API)
The compatibility API (/v1) keeps the request and response shapes of the v1 agent-verification API that existing integrations were built on (verify, identify, session lookup and the discovered API export), so such an integration works against Telltale unchanged. New integrations use /api/v1, which also returns reasons, the decision, the policy and attribution and works with the server adapter.
POST /v1/verify, POST /v1/identify, GET /v1/sessions/{sessionId} and GET /v1/discovery/openapi.json use the v1 codes below. Request bodies are closed, so an extra property is a 400. The served adapter calls /api/v1, so this table has no adapter column.
| Status | Code | What it means | What to do |
|---|---|---|---|
| 400 | bad_request | Invalid JSON, an unknown property, a missing or empty sessionToken, an actionId outside ^[a-z0-9_-]{1,64}$ (lowercase only), invalid IDs, or a body over 131,072 bytes. | Fix the request. |
| 401 | unauthorized | Missing or wrong server key, an Authorization header without the Bearer scheme, or an invalid sessionToken: over 250 characters, shaped like a key, malformed, or not issued by this Telltale server (for example edited or copied from another Telltale server). | Check the server key first, then replace only the session token. Send the browser's session token, not a key. |
| 403 | forbidden | The token belongs to a different Site, or the call came from a browser page on the public collector origin. | Use the issuing Site's server key, from your backend. |
| 404 | not_found | Unknown path, or the wrong method (for example GET /v1/verify). | Check the method and path. |
| 409 | session_reinit_required | The token was issued by this Telltale server, but its session expired, was revoked or was removed, or identify tried to replace an established identity. | Recover with a fresh browser session (below). |
| 429 | rate_limited | The server key exceeded its budget (shared with /api/v1). | Wait for Retry-After. |
| 500 | internal_error | Unexpected failure. | Fail closed and report it. |
| 503 | config_unavailable | Storage was busy while the Site's key was being checked. | Verify may retry, at most three attempts, with 0–250 ms then 0–500 ms jitter. |
| 503 | session_unavailable | Storage was busy while the session was being looked up. | Same as config_unavailable. |
| 503 | discovery_unavailable | Discovery is off for this Site (GET /v1/discovery/openapi.json only). | Turn on discovery for the Site in the dashboard. |
| 503 | discovery_not_ready | Discovery is on, but nothing has been observed yet. | Use the application with discovery on, then try again later. |
Session lookup (GET /v1/sessions/{sessionId})
The lookup takes the server key, never a session token. It is a read: retrying it never extends the session or changes its verdict. Every response, errors included, has Cache-Control: no-store.
| Status | Code | What it means | What to do |
|---|---|---|---|
| 400 | bad_request | The session ID is malformed (it isn't session_ followed by 8 to 64 letters, digits, - or _). | Fix the request. |
| 401 | unauthorized | The server key is missing, wrong, revoked or not sent with the Bearer scheme. A session ID or session token in its place is refused too. | Check the server key. |
| 404 | session_not_found | The session is unknown, belongs to another Site, was erased or deleted, or is past retention. Which one is never said. | Don't guess which. A session past retention can't be brought back. |
| 405 | none (plain text) | Any method other than GET. The response has Allow: GET. | Use GET. |
| 429 | rate_limited | The server key exceeded its budget (shared with every server-API call). | Wait for Retry-After. |
| 503 | config_unavailable or session_unavailable | Storage was busy reading the Site or the session. | Try again later. Don't treat an unavailable lookup as unknown. |
unknown in a successful answer is a real result: the evidence wasn't enough. A failed lookup is not a verdict.
The adapter still recognises config_unavailable and session_unavailable as retryable, so the same retry policy works against either dialect.
Errors the adapter raises itself
createTelltaleServer() in /adapters/v1/server.js throws an Error with status and code for every failure above. It also raises these:
| Error | When | What to do |
|---|---|---|
code unknown_error | The Telltale server sent a code the adapter does not know for that status, including request_failed. | Handle it as a failed request. |
code invalid_error_response | An error response was not JSON. A proxy or load balancer usually answered instead of the Telltale server. | Check what sits between your backend and the Telltale server. |
| status 502, message "Invalid Telltale response" | A success response failed validation: wrong shape, a verdict that does not match its score, or a decision that does not follow the policy. | Fail closed. Do not run the protected action. |
| TypeError | Invalid input before any request: a missing session token, an invalid actionId, eventId or units, or a bad endpoint or key. | Fix the call site. |
code network_unavailable (name NetworkError or TimeoutError; reason unreachable or timeout) | The Telltale server could not be reached (refused connection, DNS failure) or sent no response within the five-second deadline. The original error is in cause. | Fail closed. The adapter has already stopped retrying. |
| TimeoutError or AbortError | The deadline passed while the response body was being read, or your own signal aborted (your abort reason is rethrown unchanged). | Fail closed. The adapter has already stopped retrying. |
| status 403 with decision | requireAllowed() saw review or deny. | Apply your review or deny path. This is a decision, not a failure. |
Retry policy
Only verification retries, and only inside one five-second deadline:
- A valid 503 body with service_unavailable, config_unavailable or session_unavailable is retried, at most three attempts in total, waiting a random 0–250 ms and then 0–500 ms.
- A 408 or 429 is retried once, only when Retry-After (seconds or an HTTP date) still fits inside the deadline. Without Retry-After it fails.
- Everything else fails at once: 4xx codes, 500, malformed responses, network errors and timeouts.
The protected application action itself is never retried by Telltale, and your code should not retry it because verification failed. If you call the API without the adapter, follow the same rules. The Server adapters and raw HTTP guide has a fetch example.
Recover from session_reinit_required
The token is genuine but can no longer be verified. It expired, consent was withdrawn (which revokes it), its session was removed, or it is bound to a different user. Retrying with the same token always fails. A token that is malformed, edited or from another Telltale server is a different failure: 401 unauthorized.
- On your backend, stop. Do not run the protected action, and answer with a response your page can recognise, for example 409 {"retry": "new_session"}.
- In the browser, start a fresh session: await client.reset(), identify the current user again from your own authentication, then read a new token with await client.getSessionToken().
- Send the protected request again once, with the new token. If it fails again, show an error and stop.
async function exportWithRecovery(reportId) {
for (let attempt = 0; attempt < 2; attempt++) {
const sessionToken = await client.getSessionToken();
const response = await fetch('/api/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken, reportId })
});
if (response.status !== 409 || attempt) return response;
await client.reset();
await client.identify(await currentIdentity()); // from your own authentication
}
}A fresh session starts with little evidence, so its verdict is often unknown at first. Your policy for unknown decides what happens, and unknown is never human. The browser SDK already recovers once by itself when its own collector calls report session_reinit_required, so this loop is only for tokens your backend rejected.
Browser contexts per session
A session token is reused across page loads for up to 24 hours, and every page start sends evidence under a new browser context. The session keeps per-context bookkeeping for its 100 most recent contexts and forgets the oldest past that; it never refuses evidence or ends the session for the number of contexts, so a person who opens many pages keeps one session, its server-verified identity and its verdict. Older collectors answered 409 session_reinit_required at 100 contexts; the browser SDK still recovers from that by starting a fresh session.
Session capacity
Each Site keeps at most 100,000 live browser sessions (a session lives 24 hours unless it is revoked; operators can change the cap with TELLTALE_SESSION_CAP). Live sessions are never evicted to make room. At the cap, POST /api/sdk/init answers 503 with code session_capacity_reached and Retry-After; existing sessions keep verifying normally. The browser SDK treats it like any other temporary collector failure: client.status() reports degraded and initialization retries in the background. Other Sites are unaffected.
Dashboard API codes
The dashboard's own API (/api/admin, /api/auth) answers {"error": "<message>", "code": "<code>"} when a refusal has a stable code. The message is written for people and may be reworded; branch on code. These routes are not part of the server integration contract, but the codes stay stable.
| Status | Code | Where | What it means | What to do |
|---|---|---|---|---|
| 401 | sign_in_required | /api/admin/* | No dashboard sign-in (password mode), or it ended. | Sign in again, then repeat the request. |
| 403 | workspace_denied | /api/admin/* | X-Team-Id names a workspace this person is not a member of (another database, or a removed membership). | Drop X-Team-Id to use the default workspace, or ask an owner for an invitation. |
| 403 | sample_read_only | /api/admin/* | A change was asked for in the built-in sample workspace (X-Team-Id team_sample_builtin). It is read-only for everyone, whatever their role. | Leave the sample workspace and make the change in your own workspace. |
| 503 | sample_preparing | /api/admin/* | The built-in sample workspace is still being generated on this server (the first time, and once a day). Retry-After says when to ask again. | Wait a few seconds and ask again. |
| 403 | cross_site | /api/admin/* | A browser request from another site (Sec-Fetch-Site is neither same-origin nor none). | Open the dashboard at its own address; the dashboard API is never called from other sites. |
| 403 | invite_wrong_account | POST /api/auth/join | The signed-in account is not the invited email. | Sign out (the dashboard offers Switch account) and sign in with the invited email. |
| 404 | invite_invalid | GET /api/auth/invite | The invitation link is unknown, used, revoked or expired. | Ask the workspace owner for a new invitation. |
| 400 | workspace_limit | POST /api/admin/teams | You already own the most workspaces one person may own (5 by default), or the server has reached its workspace limit. | Delete a workspace you no longer use, or contact support. |
| 400 | invalid_name | PUT /api/admin/teams/{id} | The new workspace name is empty or longer than 60 characters. | Enter a name of 1–60 characters. |
| 409 | workspace_not_empty | DELETE /api/admin/teams/{id} | The workspace still has sites. | Remove its sites first, then delete it. |
| 400 | last_workspace | DELETE /api/admin/teams/{id} | It is your only workspace. | Create or join another workspace first. A person always keeps one. |
| 400 | confirm_required | DELETE /api/admin/sites/{id} | The body has no confirm field. | Send {"confirm": "<the Site's exact name>"}. |
| 400 | confirm_mismatch | DELETE /api/admin/sites/{id} | confirm is not the Site's exact name. | Type the name exactly as shown, including capitals and spaces. |
| 409 | site_protected | DELETE /api/admin/sites/{id} | The built-in playground and public demo Sites can't be deleted. | Nothing to do: they hold only demonstration data. |
| 404 | not_pending | POST /api/admin/site-deletions/{id}/restore, POST /api/admin/teams/{id}/restore | The Site or workspace is not pending deletion here: it was restored, its 30 days ended, or it belongs elsewhere. | Check the list in the pending-deletion banner (GET /api/admin/site-deletions, /api/admin/team-deletions). |
| 409 | site_cap | POST /api/admin/site-deletions/{id}/restore | The workspace already has as many Sites as a workspace may hold (20). | Delete another Site first, then restore this one. |
| 409 | subscription_active | DELETE /api/admin/teams/{id} | The workspace has a live paid subscription. | Cancel it in Settings › Plan & billing (Manage billing), then delete the workspace. |
| 409 | already_member | POST /api/admin/invite | The email belongs to someone already in this workspace. | Change their role in Members instead of inviting them again. |
| 409 | review_stale | Tool review and turning a tool on | The action or update changed since the review was opened. | Reload the review and decide again. |
Warnings on success
A successful verify can still carry a warning. policy_missing means no policy is saved for that actionId, so the default policy decided (Human: allow, AI agent: hold for review, Unknown: allow until you save a rule that holds or denies it, No recent activity: deny). It arrives as warnings: ["policy_missing"] in the /api/v1/verify body and as an Observatory-Warning response header on /api/v1/verify and /v1/verify. Save a policy or fix the actionId; GET /api/v1/integration-status lists the affected actionIds.
Rate limits
Budgets are counted in memory, per server process, over the window each row names (most are per minute). Each kind of traffic has its own budget, so browser evidence never uses up your backend's verification budget, even when both come from the same address:
| Traffic | Budget | Keyed by | Name |
|---|---|---|---|
| Server API (/api/v1 and /v1 together) | 12,000 a minute | Server key ID. A retiring key and its replacement have separate budgets. | serverKey |
| Failed server authentication (missing, malformed or unknown key) | 1,800 a minute | Client address | serverUnauthenticated |
| Browser SDK calls that carry a session token (all /api/sdk routes together) | 600 a minute | Session | sdkSession |
| Browser SDK evidence uploads (POST /api/sdk/events), all tabs of one session together | 600 a minute | Session | sdkEventsSession |
| Browser SDK evidence uploads from one tab (one browser context) | 120 a minute | Session and browser context | sdkEvents |
| Browser SDK calls with an invalid or expired token | 600 a minute | Client address | sdkInvalid |
| Browser SDK calls whose publishable key or Origin is refused | 600 a minute | Client address | sdkUnauthenticated |
| SDK initialization | 120 a minute per Site, 600 across all Sites | Client address (IPv6 grouped by /48), plus 6,000 a minute per Site in total | initPerIp, initPerIpAllSites, initPerSite |
| SDK tool manifest (GET /api/sdk/tools) | 600 a minute | Client address and Site | sdkTools |
| CORS preflight (OPTIONS /api/sdk/*) | 3,000 a minute | Client address | preflight |
| MCP docs server (/mcp) | 600 a minute | Client address | |
| Public docs on the public collector origin | 300 a minute (30 for /llms-full.txt) | Client address | |
| Public demo pages and API on the public collector origin | 600 a minute | Client address | |
| Retired demo email sign-ups, on a server you run yourself only (accepted sign-ups; the demo no longer asks for an email) | 3 an hour and 10 a day per address, 60 an hour in all | Client address, and all sign-ups | waitlistPerIp, waitlistGlobal |
| Refused tries on that retired sign-up route | 30 an hour | Client address | waitlistRejectedPerIp |
| Dashboard sign-in and account routes (/api/auth, except status) | 30 a minute | Client address | auth |
| Dashboard sign-in status (GET /api/auth/status) | 600 a minute | Client address | authStatus |
| Failed sign-ins for one email | 10 per 15 minutes, with growing delays | signInFailures | |
| Other /api paths | 1,800 a minute | Client address | ip |
A refused sign-in answers the same generic 429 whichever budget was used up.
Every budgeted response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds until the window resets). A 429 also carries Retry-After in seconds. The browser SDK defers its own collector calls until then and never replays application writes. The server adapter retries verification once after a 429 only when Retry-After fits inside its five-second deadline; otherwise the call fails with rate_limited and you fail closed.
Operators can raise the main budgets with TELLTALE_RATE_SERVER_KEY, TELLTALE_RATE_SDK_SESSION, TELLTALE_RATE_SDK_EVENTS, TELLTALE_RATE_INIT_PER_IP, TELLTALE_RATE_INIT_PER_IP_ALL_SITES, TELLTALE_RATE_INIT_PER_SITE and TELLTALE_RATE_IP (whole requests per minute).
Client addresses behind a reverse proxy
By default the client address is the TCP peer. Behind a reverse proxy every request then shares the proxy's address, which matters only for the per-address budgets above (initialization, failed authentication, invalid tokens). Set TRUST_PROXY=1 (or the number of proxy hops you operate, up to 5) only when every request reaches the Telltale server through proxies you control that append to X-Forwarded-For; the Telltale server then reads the entry that many hops from the right and ignores anything a client wrote further left. TRUST_PROXY is off by default. The public collector origin (Cloudflare Tunnel) uses CF-Connecting-IP as before. TRUST_PROXY does not make a public listener safe: the dashboard listener stays loopback-only.