Tether
DocsGitHub

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

ChannelShapeExample
HTTP status{ "error": { "code", "message" } }A missing API key
Tool resultFed to the model, never to youYour 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.

StatusCodeRouteCause
400empty_prompt/v1/chatprompt missing or whitespace
400invalid_request/v1/actions, /mcp, PUT /v1/endpointA tool name is missing, the body is not a JSON-RPC object or batch, or an endpoint body has no url
400invalid_originPUT /v1/originsAn origin that is not a scheme, host and optional port, or is http and not loopback
400too_many_originsPUT /v1/originsThe list does not fit in 2048 characters
400invalid_endpointPUT /v1/endpointNot an absolute HTTPS address, or one the address checks refuse
400invalid_email, invalid_name/v1/signup, POST /v1/embed/instructionsThe address or the name is missing or malformed; see Signup
400invalid_linkPOST /v1/signup/confirm, POST /v1/signup/verifyThe confirmation link was changed or is not one this instance issued. Ask for a new one with POST /v1/account/confirmation; see Signup
400link_expiredPOST /v1/signup/confirm, POST /v1/signup/verifyThe confirmation link is older than Signup:LinkLifetimeMinutes (60 by default). Ask for a new one
400link_usedPOST /v1/signup/confirmThe confirmation link was already used, or a newer link or a changed address replaced it; only the newest link sent confirms
400invalid_site_url, invalid_feed_url/v1/preview, POST /v1/preview/sandbox, POST /v1/embed/checkThe address is refused: not http or https, carrying a user name or password, or plain http where the deployment requires https
400site_refusedPOST /v1/preview/sandboxThe site or feed resolves to a private, loopback or unresolvable address; nothing was created. See Preview Mode
400invalid_image/v1/chat, /v1/chat/streamThe 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
400invalid_upstream_tokenany that carries X-Tether-Upstream-TokenThe 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
400invalid_period/v1/outcomes, /v1/provider/outcomes, /v1/tenants/outcomesto is earlier than from; see Outcomes
400invalid_knowledge_name, invalid_knowledge_format, knowledge_emptyPOST /v1/knowledgeThe name is not 1 to 80 letters, digits, -, _ or .; the format is not text or markdown; or there is no text. See Knowledge
400invalid_sourcePOST /v1/knowledge, POST /v1/domain-pack/suggestionssource 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/usersA rejected body. It is { "registered": false, "error": "…" }, { "valid": false, "error": "…" }, { "imported": false, "error": "…" } or { "error": "…" }
404not_foundanyNo such route. A resource that does not exist, such as an unknown action, answers with its own body instead
405method_not_allowedanyThe route exists but not for this HTTP verb
401missing_api_keyanyNo X-Api-Key and no bearer token
401invalid_api_keyanyUnknown, revoked, or inactive tenant
401invalid_customer_tokenany that carries X-Tether-Customer-TokenThe 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
401invalid_user_sessionany that carries X-Tether-User-SessionThe session acts for nobody. error.reason is closed, expired, unknown, malformed or user_inactive; see User sessions
401invalid_sign_in_linkPOST /v1/user-sessionsThe link did not open. error.reason is spent, expired, unknown, malformed or user_inactive
403customer_required/v1/actions, /v1/tools/{name}/callThe capability declares requires_customer and the request carried no verified customer. In the body's status, not error.code
403user_token_required/v1/tools/{name}/callThe 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
403insufficient_scopeanyThe 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
400invalid_suggestionPOST /v1/domain-pack/suggestionslexicon is not an object of 1 to 40 domains, each 1 to 200 words of at most 60 characters
400invalid_statusGET /v1/domain-pack/suggestionsstatus is not pending, accepted, dismissed, superseded or all
404suggestion_not_found/v1/domain-pack/suggestions/{id}/accept, /dismissThis tenant holds no suggestion with that id
409suggestion_not_pending/v1/domain-pack/suggestions/{id}/accept, /dismissThe suggestion was already accepted, dismissed or superseded; the message says which
403session_not_yours/v1/chat, /v1/chat/streamsessionId 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
403email_unconfirmedPUT /v1/origins, POST /v1/user-sessions/sign-in, POST /v1/keys with a plan, POST /v1/tenants/{id}/provider, POST /v1/provider/tenantsGoing 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
403origin_not_allowedany, from a browserThe page's origin is not registered; see the widget guide
403not_a_provider/v1/providerThe tenant no longer holds provider authority; see Providers
404not_a_child_tenant/v1/provider/tenants/{id}That id is not a business this provider created, whether or not it exists
404signup_disabled/v1/signupSelf-serve signup is not open on this instance
404preview_disabled/v1/previewPreview Mode is not enabled
404sandbox_disabledPOST /v1/preview/sandboxThe preview sandbox is not open on this instance (PreviewSandbox:Enabled)
409sandbox_not_ready/v1/chat, /v1/chat/stream with a tsb_ keyThe sandbox's site is still being read, or nothing on it could be
410sandbox_expired/v1/chat, /v1/chat/stream, POST /v1/preview/sandbox/claim, POST /v1/signup/verifyThe sandbox passed its lifetime, was swept, or was already kept. Start a new one
429sandbox_budget_exhausted/v1/chat, /v1/chat/stream with a tsb_ keyNo 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
503sandbox_busyPOST /v1/preview/sandboxEvery crawl slot on the instance is in use; nothing was created. Try again in a minute
503sandbox_unavailablePOST /v1/preview/sandboxThe sandbox is enabled but has no PreviewSandbox:IpHashSalt
404—PATCH /v1/tools/apiNo imported API is connected for this tenant; import one first. { "error": "…" }
404knowledge_document_not_foundDELETE /v1/knowledge/{name}This tenant holds no document by that name
400invalid_feedback/v1/chat/feedbackA missing sessionId or requestId, a rating other than up or down, or a comment over 1,000 characters; see Feedback
404session_not_foundPOST /v1/chat/feedbackNo conversation with that id belongs to this caller: another tenant's, or a verified customer's without that customer's token
409feedback_in_flightPOST /v1/chat/feedbackThe same answer was rated at the same moment; send the rating again
400invalid_brand_colorPUT /v1/chat/brandcolor is not a six-digit hex colour such as #1f7a5a; see the widget's brand
400invalid_brand_logoPUT /v1/chat/brandlogo is not an http or https address of at most 2048 characters, or carries a user name
404knowledge_disabled/v1/knowledgeKnowledge documents are switched off on this deployment
404—/v1/actions/{id}/confirmAn unknown or already-executed action; see Actions
400disposable_email/v1/signup, POST /v1/account/confirmationA disposable or blocked mail domain
400confirmation_requiredDELETE /v1/accountconfirm does not match the workspace name; see Account
409precondition_failed/v1/actionsThe write's precondition does not hold, so nothing was staged. In the body's status, not error.code
409knowledge_document_existsPOST /v1/knowledgeA document by that name exists. Send replace: true, or delete it first
409knowledge_limit_reachedPOST /v1/knowledgeThe tenant already holds its maximum number of documents
409key_limit_reached/v1/account/keysTen live keys already; revoke one
409last_secret_key/v1/account/keys/{prefix}, /v1/provider/tenants/{id}/keys/{prefix}The only live secret key cannot be revoked; rotate it
409provisioned_tenantPOST /v1/tenants/{id}/providerThat tenant was created by a provider, and a provider's business cannot itself be made a provider; see Providers
409email_already_used/v1/signup, POST /v1/account/confirmationThat address already has a development tenant
409user_link_conflictPUT /v1/usersAn 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
409user_email_ambiguousPOST /v1/users/invitationsTwo registered people share that address, so the invitation cannot tell which is meant
429invitation_limitPOST /v1/users/invitationsThe workspace sent Mail:MaxInvitationsPerHour invitations in the last hour; nobody was registered
429embed_mail_limitPOST /v1/embed/instructionsThe workspace sent the embed instructions Mail:MaxEmbedInstructionsPerHour (5) times in the last hour; copy the snippet instead. See Embed
409already_confirmedPOST /v1/account/confirmationThe workspace's address is already confirmed and no other address was given
503mail_not_configuredPOST /v1/user-sessions/sign-inThis deployment sends no mail; see Mail
410action_expired/v1/actions/{id}/confirmThe confirmation window passed
413batch_too_large/mcpA JSON-RPC batch with more requests than the limit
413knowledge_too_largePOST /v1/knowledgeThe document's text is over the per-document limit
422preview_empty/v1/previewNothing readable was found at that address
429rate_limited/v1/signup, POST /v1/preview/sandbox, POST /v1/account/confirmationToo 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
429plan_quota_exceeded/v1/chatA free tenant used its daily request allowance. Retry-After is set
429rate_limitedanyOver 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
451action_prohibited/v1/actions/{id}/confirmRefused under the child-safety policy. No approver can clear it
500internal_erroranyAn unhandled failure. The body carries an errorId; quote it when you report the problem
503signup_unavailable/v1/signupNothing 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": "…"}.

StatusMeaningRetried
unknown_toolThe model called a name not in the catalogno
not_configuredThe tenant has no tool endpointno
invalid_endpointNot absolute, not HTTPS, or resolves to a private addressno
endpoint_errorNon-2xx from your endpointon 408 429 502 503 504
timeoutNo response within TimeoutSecondsreads only
unreachableConnection failure, DNS failure, TLS failurereads only
response_too_largeBody exceeded MaxResponseBytesno
server_errorA 5xx on a confirmed write. The write may have happened, so it is held for reconcileno
in_flightYour endpoint answered that another request holds this idempotency keyno
refusedYour 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 settledno
indeterminateYour imported API answered a confirmed write with a status the operation declares indeterminate. Held for reconcileno
failedEvery attempt was exhausted—
customer_requiredThe 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 inno
precondition_failedThe 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 itno
precondition_errorThe precondition's read failed or did not answer, so whether the write may run is unknown. Nothing was stagedthe 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

CodeWhereRecovers
ORG_DAILY_BUDGET_EXCEEDEDSSE frame, telemetryNext UTC day
ORG_MONTHLY_BUDGET_EXCEEDEDSSE frame, telemetryNext UTC month
GLOBAL_DAILY_BUDGET_EXCEEDEDSSE frame, telemetryNext UTC day
GLOBAL_MONTHLY_BUDGET_EXCEEDEDSSE frame, telemetryNext UTC month
RATE_LIMITEDSSE frameAfter retryAfterSeconds
rate_limited429 on any pathAfter 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_pathMeaning
okNormal
out_of_scopeThe policy refused before any spend
budget_exceededThe breaker refused before any spend
errorThe turn failed; fallback_mode says how
canceledThe client disconnected