API reference
Errors
Failures surface in four different places, and only one of them is an HTTP status code. Knowing which is which is most of handling them correctly.
The four channels
| Channel | Shape | Example |
|---|---|---|
| HTTP status | { "error": { "code", "message" } } | A missing API key |
| Tool result | Fed to the model, never to you | Your endpoint timed out |
| Confirm status | { "status", "errorCode", "errorMessage" } | A missing risk acknowledgement |
| SSE error frame | { "type": "error", "code", "message" } | An upstream rate limit |
HTTP status codes
A request body is always JSON. One sent with no Content-Type, or with the form type curl -d
sends by default, is read as JSON, so a bare curl -d '{…}' works; any other content type is
refused with 415.
| Status | Code | Route | Cause |
|---|---|---|---|
400 | empty_prompt | /v1/chat | prompt missing or whitespace |
400 | invalid_request | /v1/actions, /mcp, PUT /v1/endpoint | A tool name is missing, the body is not a JSON-RPC object or batch, or an endpoint body has no url |
400 | invalid_origin | PUT /v1/origins | An origin that is not a scheme, host and optional port, or is http and not loopback |
400 | too_many_origins | PUT /v1/origins | The list does not fit in 2048 characters |
400 | invalid_endpoint | PUT /v1/endpoint | Not an absolute HTTPS address, or one the address checks refuse |
400 | invalid_email, invalid_name | /v1/signup, POST /v1/embed/instructions | The address or the name is missing or malformed; see Signup |
400 | invalid_link | POST /v1/signup/confirm, POST /v1/signup/verify | The confirmation link was changed or is not one this instance issued. Ask for a new one with POST /v1/account/confirmation; see Signup |
400 | link_expired | POST /v1/signup/confirm, POST /v1/signup/verify | The confirmation link is older than Signup:LinkLifetimeMinutes (60 by default). Ask for a new one |
400 | link_used | POST /v1/signup/confirm | The confirmation link was already used, or a newer link or a changed address replaced it; only the newest link sent confirms |
400 | invalid_site_url, invalid_feed_url | /v1/preview, POST /v1/preview/sandbox, POST /v1/embed/check | The address is refused: not http or https, carrying a user name or password, or plain http where the deployment requires https |
400 | site_refused | POST /v1/preview/sandbox | The site or feed resolves to a private, loopback or unresolvable address; nothing was created. See Preview Mode |
400 | invalid_image | /v1/chat, /v1/chat/stream | The turn's image is not one AI:Multimodal allows. error.reason is disabled, both_sources, missing_type, unsupported_type, not_base64, too_large, type_mismatch, insecure_url or host_not_allowed, and error.message names the limit; see Image turns |
400 | invalid_upstream_token | any that carries X-Tether-Upstream-Token | The token is malformed. error.reason is empty, too_long (over 4000 characters), invalid_characters (whitespace, control or non-ASCII) or repeated; the message never quotes it. See Calling your API as the signed-in user |
400 | invalid_period | /v1/outcomes, /v1/provider/outcomes, /v1/tenants/outcomes | to is earlier than from; see Outcomes |
400 | invalid_knowledge_name, invalid_knowledge_format, knowledge_empty | POST /v1/knowledge | The name is not 1 to 80 letters, digits, -, _ or .; the format is not text or markdown; or there is no text. See Knowledge |
400 | invalid_source | POST /v1/knowledge, POST /v1/domain-pack/suggestions | source is not {kind: "repository", commit, path, dirty} with each field well formed; the message names the field. See Knowledge |
400 | — | /v1/tools, /v1/tools/import, PATCH /v1/tools/api, /v1/policy, /v1/users | A rejected body. It is { "registered": false, "error": "…" }, { "valid": false, "error": "…" }, { "imported": false, "error": "…" } or { "error": "…" } |
404 | not_found | any | No such route. A resource that does not exist, such as an unknown action, answers with its own body instead |
405 | method_not_allowed | any | The route exists but not for this HTTP verb |
401 | missing_api_key | any | No X-Api-Key and no bearer token |
401 | invalid_api_key | any | Unknown, revoked, or inactive tenant |
401 | invalid_customer_token | any that carries X-Tether-Customer-Token | The customer token did not verify. error.reason is one of malformed, unsupported_algorithm, not_configured, wrong_tenant, invalid_signature, invalid_subject, issued_in_future, expired, lifetime_too_long; see Verified customers |
401 | invalid_user_session | any that carries X-Tether-User-Session | The session acts for nobody. error.reason is closed, expired, unknown, malformed or user_inactive; see User sessions |
401 | invalid_sign_in_link | POST /v1/user-sessions | The link did not open. error.reason is spent, expired, unknown, malformed or user_inactive |
403 | customer_required | /v1/actions, /v1/tools/{name}/call | The capability declares requires_customer and the request carried no verified customer. In the body's status, not error.code |
403 | user_token_required | /v1/tools/{name}/call | The imported API's userToken mode is require and the request carried no X-Tether-Upstream-Token, so the API was not called. In the body's status, not error.code. On a confirm it is FailedTerminal with this errorCode; see Confirming a write |
403 | insufficient_scope | any | The credential may not reach that route; see Authentication. For an inbox key, error.reason is user_session_required or not_an_inbox_route. For a CI key, not_a_ci_route (any route a publish does not use), ci_draft_only (an import carrying operations, a credential or skipUndeclared), ci_source_required (no repository source) or ci_replace_not_allowed (replace: true from a key not issued to replace); see Repository. For a preview sandbox key (tsb_), not_a_sandbox_route; a workspace key on the sandbox's own routes, sandbox_credential_required |
400 | invalid_suggestion | POST /v1/domain-pack/suggestions | lexicon is not an object of 1 to 40 domains, each 1 to 200 words of at most 60 characters |
400 | invalid_status | GET /v1/domain-pack/suggestions | status is not pending, accepted, dismissed, superseded or all |
404 | suggestion_not_found | /v1/domain-pack/suggestions/{id}/accept, /dismiss | This tenant holds no suggestion with that id |
409 | suggestion_not_pending | /v1/domain-pack/suggestions/{id}/accept, /dismiss | The suggestion was already accepted, dismissed or superseded; the message says which |
403 | session_not_yours | /v1/chat, /v1/chat/stream | sessionId names a conversation this guest may not continue. error.reason is other_customer, customer_session, anonymous_session or not_issued; start a new one by omitting it. See Context and memory |
403 | email_unconfirmed | PUT /v1/origins, POST /v1/user-sessions/sign-in, POST /v1/keys with a plan, POST /v1/tenants/{id}/provider, POST /v1/provider/tenants | Going live waits until the workspace's signup address is confirmed. error.reason says which part: public_origin (an origin on the public internet; local ones work), third_party_mail (mail to anyone but the signup address; a held approval notice or invitation goes out on confirmation), paid_plan, provider_grant or child_tenant. error.resend names POST /v1/account/confirmation, which sends the link again. See Signup |
403 | origin_not_allowed | any, from a browser | The page's origin is not registered; see the widget guide |
403 | not_a_provider | /v1/provider | The tenant no longer holds provider authority; see Providers |
404 | not_a_child_tenant | /v1/provider/tenants/{id} | That id is not a business this provider created, whether or not it exists |
404 | signup_disabled | /v1/signup | Self-serve signup is not open on this instance |
404 | preview_disabled | /v1/preview | Preview Mode is not enabled |
404 | sandbox_disabled | POST /v1/preview/sandbox | The preview sandbox is not open on this instance (PreviewSandbox:Enabled) |
409 | sandbox_not_ready | /v1/chat, /v1/chat/stream with a tsb_ key | The sandbox's site is still being read, or nothing on it could be |
410 | sandbox_expired | /v1/chat, /v1/chat/stream, POST /v1/preview/sandbox/claim, POST /v1/signup/verify | The sandbox passed its lifetime, was swept, or was already kept. Start a new one |
429 | sandbox_budget_exhausted | /v1/chat, /v1/chat/stream with a tsb_ key | No more answers for this sandbox. error.reason is sandbox_limit (its own answers or spend are used up) or daily_limit (every sandbox together has spent the day's budget). Keeping it as a workspace lifts both |
503 | sandbox_busy | POST /v1/preview/sandbox | Every crawl slot on the instance is in use; nothing was created. Try again in a minute |
503 | sandbox_unavailable | POST /v1/preview/sandbox | The sandbox is enabled but has no PreviewSandbox:IpHashSalt |
404 | — | PATCH /v1/tools/api | No imported API is connected for this tenant; import one first. { "error": "…" } |
404 | knowledge_document_not_found | DELETE /v1/knowledge/{name} | This tenant holds no document by that name |
400 | invalid_feedback | /v1/chat/feedback | A missing sessionId or requestId, a rating other than up or down, or a comment over 1,000 characters; see Feedback |
404 | session_not_found | POST /v1/chat/feedback | No conversation with that id belongs to this caller: another tenant's, or a verified customer's without that customer's token |
409 | feedback_in_flight | POST /v1/chat/feedback | The same answer was rated at the same moment; send the rating again |
400 | invalid_brand_color | PUT /v1/chat/brand | color is not a six-digit hex colour such as #1f7a5a; see the widget's brand |
400 | invalid_brand_logo | PUT /v1/chat/brand | logo is not an http or https address of at most 2048 characters, or carries a user name |
404 | knowledge_disabled | /v1/knowledge | Knowledge documents are switched off on this deployment |
404 | — | /v1/actions/{id}/confirm | An unknown or already-executed action; see Actions |
400 | disposable_email | /v1/signup, POST /v1/account/confirmation | A disposable or blocked mail domain |
400 | confirmation_required | DELETE /v1/account | confirm does not match the workspace name; see Account |
409 | precondition_failed | /v1/actions | The write's precondition does not hold, so nothing was staged. In the body's status, not error.code |
409 | knowledge_document_exists | POST /v1/knowledge | A document by that name exists. Send replace: true, or delete it first |
409 | knowledge_limit_reached | POST /v1/knowledge | The tenant already holds its maximum number of documents |
409 | key_limit_reached | /v1/account/keys | Ten live keys already; revoke one |
409 | last_secret_key | /v1/account/keys/{prefix}, /v1/provider/tenants/{id}/keys/{prefix} | The only live secret key cannot be revoked; rotate it |
409 | provisioned_tenant | POST /v1/tenants/{id}/provider | That tenant was created by a provider, and a provider's business cannot itself be made a provider; see Providers |
409 | email_already_used | /v1/signup, POST /v1/account/confirmation | That address already has a development tenant |
409 | user_link_conflict | PUT /v1/users | An entry would make two people one, or one person two: a userId already another person's while the email belongs to a person invited and not linked, one address linked by two entries, a person already linked to another userId, or an id this workspace does not have. Nothing in the request was written; error names the entry. See Approvers |
409 | user_email_ambiguous | POST /v1/users/invitations | Two registered people share that address, so the invitation cannot tell which is meant |
429 | invitation_limit | POST /v1/users/invitations | The workspace sent Mail:MaxInvitationsPerHour invitations in the last hour; nobody was registered |
429 | embed_mail_limit | POST /v1/embed/instructions | The workspace sent the embed instructions Mail:MaxEmbedInstructionsPerHour (5) times in the last hour; copy the snippet instead. See Embed |
409 | already_confirmed | POST /v1/account/confirmation | The workspace's address is already confirmed and no other address was given |
503 | mail_not_configured | POST /v1/user-sessions/sign-in | This deployment sends no mail; see Mail |
410 | action_expired | /v1/actions/{id}/confirm | The confirmation window passed |
413 | batch_too_large | /mcp | A JSON-RPC batch with more requests than the limit |
413 | knowledge_too_large | POST /v1/knowledge | The document's text is over the per-document limit |
422 | preview_empty | /v1/preview | Nothing readable was found at that address |
429 | rate_limited | /v1/signup, POST /v1/preview/sandbox, POST /v1/account/confirmation | Too many signups or sandboxes from this address recently, or across every caller in the last hour; or more confirmation links than Signup:MaxLinksPerEmailPerHour for one workspace in an hour |
429 | plan_quota_exceeded | /v1/chat | A free tenant used its daily request allowance. Retry-After is set |
429 | rate_limited | any | Over the per-minute request limit for your credential (or, with no credential, your address). The Retry-After header says how many seconds to wait; see Configuration |
451 | action_prohibited | /v1/actions/{id}/confirm | Refused under the child-safety policy. No approver can clear it |
500 | internal_error | any | An unhandled failure. The body carries an errorId; quote it when you report the problem |
503 | signup_unavailable | /v1/signup | Nothing was created; error.reason is mail_not_configured, mail_failed or misconfigured |
Confirm statuses such as ApproverUnknown and Indeterminate have their own HTTP codes,
listed with the rest on the Actions page.
{ "error": { "code": "invalid_api_key", "message": "The API key is not valid." } }
Tool result statuses
These never reach you as HTTP errors. They are recorded in telemetry and shown to the model as
{"error": "…"}.
| Status | Meaning | Retried |
|---|---|---|
unknown_tool | The model called a name not in the catalog | no |
not_configured | The tenant has no tool endpoint | no |
invalid_endpoint | Not absolute, not HTTPS, or resolves to a private address | no |
endpoint_error | Non-2xx from your endpoint | on 408 429 502 503 504 |
timeout | No response within TimeoutSeconds | reads only |
unreachable | Connection failure, DNS failure, TLS failure | reads only |
response_too_large | Body exceeded MaxResponseBytes | no |
server_error | A 5xx on a confirmed write. The write may have happened, so it is held for reconcile | no |
in_flight | Your endpoint answered that another request holds this idempotency key | no |
refused | Your imported API refused a confirmed write without applying it: a 4xx other than 408 and 429, unless the operation declares it indeterminate. The result carries your reason; the action is settled | no |
indeterminate | Your imported API answered a confirmed write with a status the operation declares indeterminate. Held for reconcile | no |
failed | Every attempt was exhausted | — |
customer_required | The capability declares requires_customer and the turn has no verified customer. Nothing was called or staged; the model is told to ask the person to sign in | no |
precondition_failed | The write's precondition does not hold. Nothing was staged; the result carries the reason and your read's result, and the model is told to explain it | no |
precondition_error | The precondition's read failed or did not answer, so whether the write may run is unknown. Nothing was staged | the read, as a read |
Confirmed writes are never retried on any of these. See Retries, timeouts, idempotency.
Confirmation statuses
Returned in the body of POST /v1/actions/{id}/confirm, /reject and /reconcile. Every status,
its HTTP code and its errorCode are in one table on the Actions
page; they are not repeated here so the two cannot disagree.
Prohibited is the one refusal that is not a permission question. Every other status here is
resolved by the right person confirming; this one is not resolved by anyone. It is checked at
staging and again at confirm, before the permission guard, before the approval gate and before
the execution claim — so no ordering of approvals reaches it, and no administrator or operator
credential overrides it. It is recorded in the audit as its own event, prohibited, distinct
from a person's rejected.
The message states the applicable principle and nothing about what triggered it, deliberately: naming that would say precisely what to change.
Terminal failure codes carry through from your endpoint's classification:
VALIDATION_ERROR, RESOURCE_NOT_FOUND, FORBIDDEN, CONFLICT,
EXECUTION_ATTEMPTS_EXHAUSTED.
Budget and rate limits
| Code | Where | Recovers |
|---|---|---|
ORG_DAILY_BUDGET_EXCEEDED | SSE frame, telemetry | Next UTC day |
ORG_MONTHLY_BUDGET_EXCEEDED | SSE frame, telemetry | Next UTC month |
GLOBAL_DAILY_BUDGET_EXCEEDED | SSE frame, telemetry | Next UTC day |
GLOBAL_MONTHLY_BUDGET_EXCEEDED | SSE frame, telemetry | Next UTC month |
RATE_LIMITED | SSE frame | After retryAfterSeconds |
rate_limited | 429 on any path | After Retry-After |
The two tenant-scoped codes cover both a tenant's own ceiling and its plan's ceiling, which is how the free tier is capped. Nothing in the response says which one it was.
rate_limited is a different control from the budget codes above and arrives before anything is
done rather than after: a per-tenant request ceiling of 120 a minute on the paths that call a
model or your endpoint, 600 a minute on the rest. It is returned as a plain 429 with a
Retry-After header on both the buffered and streaming paths, because a refused request never
started a turn. You can be well inside your budget and still hit it.
On the buffered /v1/chat path a budget refusal is not an error at all. It returns 200
with a text block explaining the assistant is unavailable for now, and telemetry records
result_path: "budget_exceeded". Your UI needs no special case; your dashboards do.
What to retry
Retry with backoff: RATE_LIMITED (honour retryAfterSeconds), and a 5xx on a request
you know to be side-effect free.
Retry after a change: GuardRejected — after collecting the deliberate confirmation
gesture from a person.
Never retry: 401, 400 on a catalog (fix the catalog), Conflict, NotFound, and any
confirm that returned FailedTerminal.
Never retry blind: a confirm that timed out at the transport level. The write may have landed. Ask your own system before offering the button again.
Debugging a turn
Every response carries a session_id, and every turn writes a telemetry row keyed by request
id. result_path on that row is the fastest way to see what happened:
result_path | Meaning |
|---|---|
ok | Normal |
out_of_scope | The policy refused before any spend |
budget_exceeded | The breaker refused before any spend |
error | The turn failed; fallback_mode says how |
canceled | The client disconnected |