Errors and retries

View as Markdown

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 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.

/api/v1 error codes
StatusCodeWhat it meansWhat to doAdapter retries
400bad_requestThe 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
401unauthorizedThe 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
401session_reinit_requiredThe 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
403forbiddenThe 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
404not_foundUnknown path or method.Check the path against the API reference.No
404not_foundGET /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
409conflictPOST /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
409identity_requiredPOST /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
409conflictPOST /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
409approval_invalidPOST /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
409approval_usedThe 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
409approval_expiredMore than 15 minutes passed since the approval.Ask for a new review.No
409step_up_not_heldPOST /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
409step_up_mismatchThe 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
409step_up_settledThe 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
409step_up_expiredMore than 15 minutes passed since the hold.Verify again; a new hold can be stepped up.No
409step_up_supersededA 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
409approval_mismatchThe 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
409session_reinit_requiredPOST /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
413payload_too_largeThe body is larger than 131,072 bytes.Send a smaller body. Verify needs only sessionToken, actionId and an optional forwarded request.No
429rate_limitedThe 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
500internal_errorThe Telltale server failed unexpectedly.Fail closed for the protected action and report it. Retrying the same request rarely helps.No
503service_unavailableStorage 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 4xxrequest_failedA client error without a more specific code.Treat it as a failed request and fix the call.No
409agent_tokens_disabledPOST /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
409tool_not_approvedPOST /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
409tool_not_read_onlyPOST /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.

/v1 error codes (compatibility API)
StatusCodeWhat it meansWhat to do
400bad_requestInvalid 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.
401unauthorizedMissing 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.
403forbiddenThe 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.
404not_foundUnknown path, or the wrong method (for example GET /v1/verify).Check the method and path.
409session_reinit_requiredThe 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).
429rate_limitedThe server key exceeded its budget (shared with /api/v1).Wait for Retry-After.
500internal_errorUnexpected failure.Fail closed and report it.
503config_unavailableStorage 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.
503session_unavailableStorage was busy while the session was being looked up.Same as config_unavailable.
503discovery_unavailableDiscovery is off for this Site (GET /v1/discovery/openapi.json only).Turn on discovery for the Site in the dashboard.
503discovery_not_readyDiscovery 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.

Session lookup (GET /v1/sessions/{sessionId})
StatusCodeWhat it meansWhat to do
400bad_requestThe session ID is malformed (it isn't session_ followed by 8 to 64 letters, digits, - or _).Fix the request.
401unauthorizedThe 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.
404session_not_foundThe 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.
405none (plain text)Any method other than GET. The response has Allow: GET.Use GET.
429rate_limitedThe server key exceeded its budget (shared with every server-API call).Wait for Retry-After.
503config_unavailable or session_unavailableStorage 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:

Errors the adapter raises itself
ErrorWhenWhat to do
code unknown_errorThe 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_responseAn 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.
TypeErrorInvalid 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 AbortErrorThe 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 decisionrequireAllowed() 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:

  1. 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.
  2. 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.
  3. 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.

  1. 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"}.
  2. 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().
  3. Send the protected request again once, with the new token. If it fails again, show an error and stop.
JavaScript
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.

Dashboard API codes
StatusCodeWhereWhat it meansWhat to do
401sign_in_required/api/admin/*No dashboard sign-in (password mode), or it ended.Sign in again, then repeat the request.
403workspace_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.
403sample_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.
503sample_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.
403cross_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.
403invite_wrong_accountPOST /api/auth/joinThe signed-in account is not the invited email.Sign out (the dashboard offers Switch account) and sign in with the invited email.
404invite_invalidGET /api/auth/inviteThe invitation link is unknown, used, revoked or expired.Ask the workspace owner for a new invitation.
400workspace_limitPOST /api/admin/teamsYou 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.
400invalid_namePUT /api/admin/teams/{id}The new workspace name is empty or longer than 60 characters.Enter a name of 1–60 characters.
409workspace_not_emptyDELETE /api/admin/teams/{id}The workspace still has sites.Remove its sites first, then delete it.
400last_workspaceDELETE /api/admin/teams/{id}It is your only workspace.Create or join another workspace first. A person always keeps one.
400confirm_requiredDELETE /api/admin/sites/{id}The body has no confirm field.Send {"confirm": "<the Site's exact name>"}.
400confirm_mismatchDELETE /api/admin/sites/{id}confirm is not the Site's exact name.Type the name exactly as shown, including capitals and spaces.
409site_protectedDELETE /api/admin/sites/{id}The built-in playground and public demo Sites can't be deleted.Nothing to do: they hold only demonstration data.
404not_pendingPOST /api/admin/site-deletions/{id}/restore, POST /api/admin/teams/{id}/restoreThe 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).
409site_capPOST /api/admin/site-deletions/{id}/restoreThe workspace already has as many Sites as a workspace may hold (20).Delete another Site first, then restore this one.
409subscription_activeDELETE /api/admin/teams/{id}The workspace has a live paid subscription.Cancel it in Settings › Plan & billing (Manage billing), then delete the workspace.
409already_memberPOST /api/admin/inviteThe email belongs to someone already in this workspace.Change their role in Members instead of inviting them again.
409review_staleTool review and turning a tool onThe 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:

Rate limits
TrafficBudgetKeyed byName
Server API (/api/v1 and /v1 together)12,000 a minuteServer key ID. A retiring key and its replacement have separate budgets.serverKey
Failed server authentication (missing, malformed or unknown key)1,800 a minuteClient addressserverUnauthenticated
Browser SDK calls that carry a session token (all /api/sdk routes together)600 a minuteSessionsdkSession
Browser SDK evidence uploads (POST /api/sdk/events), all tabs of one session together600 a minuteSessionsdkEventsSession
Browser SDK evidence uploads from one tab (one browser context)120 a minuteSession and browser contextsdkEvents
Browser SDK calls with an invalid or expired token600 a minuteClient addresssdkInvalid
Browser SDK calls whose publishable key or Origin is refused600 a minuteClient addresssdkUnauthenticated
SDK initialization120 a minute per Site, 600 across all SitesClient address (IPv6 grouped by /48), plus 6,000 a minute per Site in totalinitPerIp, initPerIpAllSites, initPerSite
SDK tool manifest (GET /api/sdk/tools)600 a minuteClient address and SitesdkTools
CORS preflight (OPTIONS /api/sdk/*)3,000 a minuteClient addresspreflight
MCP docs server (/mcp)600 a minuteClient address
Public docs on the public collector origin300 a minute (30 for /llms-full.txt)Client address
Public demo pages and API on the public collector origin600 a minuteClient 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 allClient address, and all sign-upswaitlistPerIp, waitlistGlobal
Refused tries on that retired sign-up route30 an hourClient addresswaitlistRejectedPerIp
Dashboard sign-in and account routes (/api/auth, except status)30 a minuteClient addressauth
Dashboard sign-in status (GET /api/auth/status)600 a minuteClient addressauthStatus
Failed sign-ins for one email10 per 15 minutes, with growing delaysEmailsignInFailures
Other /api paths1,800 a minuteClient addressip

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.