Every non-2xx response carries a JSON body:
{
"error": "<machine code>",
"message": "<human description>",
"details": { /* optional, type-specific */ }
}
Drive your retry / surface logic off error, not the HTTP code or the
message text. HTTP codes are stable but coarse; error strings are stable
and precise.
Catalog
| HTTP | error | Endpoint(s) | Cause | Widget response |
|---|
| 400 | invalid_input | all | Body malformed or required field missing | Fix the payload — bug in the widget. No retry. |
| 401 | invalid_session_token | /messages, /events | Token expired, tampered, or absent | Silently re-open /sessions, retry the original request once. If 401 again, escalate. |
| 403 | widget_disabled | all | Widget key not found or admin disabled it | Stop. Show a “chat unavailable” state; the tenant admin must intervene. |
| 403 | origin_not_allowed | all | Request Origin not in the key’s allowedOrigins | Stop. Misconfiguration — get the integrator’s domain added to the key. |
| 403 | intent_not_allowed | /messages (trigger) | Requested intentName is outside the widget key’s allow-list | Bug in the widget — only use names from session.intents[*].name. details.allowed_intents lists what’s permitted. |
| 404 | intent_not_matched | /messages (trigger) | No published flow for the requested intent | Show “chat is being updated, try again soon” + report to ops. |
| 404 | execution_not_found | GET /executions/{id}, /messages (resume) | Wrong id, or the execution belongs to a different tenant or to a different conversation than your session | Bug in the widget — only poll/resume executionId values returned by /messages within the same session. |
| 409 | invalid_wait_token | /messages (resume) | Wait token expired, mismatched, or already used | Drop your cached waitToken + executionId, ask the user what they want, send a fresh trigger with text. |
| 410 | execution_aborted | /messages (resume) | Server-side cancelled the run (admin action / timeout) | Same as 409 — drop the pending wait, start fresh. |
| 422 | validation_failed | all | Field length / pattern / JSON Schema rejection | Highlight the offending fields from details.validation_errors and let the user retry. No backoff — the next call works as soon as inputs are valid. |
| 429 | rate_limited | all | Request budget exceeded — the public surface is capped at 60 req/min per IP and 600 req/min per public key, each across all /api/public/v1/* paths. | Wait the Retry-After header (seconds), then retry. Treat it as expected pressure, not an error to surface to the user. |
| 5xx | internal_error | all | Server fault | Exponential backoff with jitter, max 3 retries, then a generic “couldn’t reach support” message. |
Rate-limit responses
429 rate_limited carries a Retry-After header (integer seconds). Always
honor it instead of guessing a backoff — production deployments often
tighten the cap at the reverse proxy (Caddy / Cloudflare), and the worker
budget changes accordingly. If the header is missing (proxy stripped it),
fall back to the catalog’s exponential schedule.
The per-IP cap is intentionally generous for a single chat session
(60/min ≈ 1/s). You’ll only hit it from a runaway loop, a noisy retry
storm, or shared-NAT traffic. A second, per-public-key budget
(600/min, counted across all source IPs) backstops it: the public key is a
non-secret embedded in client JS, so a scraped key would otherwise be
bounded only per IP — trivially bypassed by rotating IPs. Normal
multi-user widget traffic never reaches the per-key cap, and the anonymous
GET reads (Public read API) are designed to be
served from HTTP caches, so they barely consume either budget.
Validation errors in detail
When error: validation_failed comes back from a /messages resume,
details.validation_errors is a list of per-field reasons:
{
"error": "validation_failed",
"message": "Submitted input failed schema validation",
"details": {
"validation_errors": [
{ "field": "order_number", "rule": "pattern", "expected": "^[A-Z0-9-]+$" },
{ "field": "email", "rule": "required" }
]
}
}
Map field to your form input and show rule (or a friendly version of
it) next to the input. Don’t show the raw expected regex to users.
Validation rule codes
rule | Meaning |
|---|
required | Field is required but the value was null / empty string / empty array. (Skipped for fields whose visibleIf evaluated to false.) |
type | Wrong JSON type — e.g. number sent where the schema expected string. |
min_length / max_length | String length outside the field’s bounds. |
minimum / maximum | Number outside the field’s range (also used by rating). |
pattern | String did not match the regex on the field. |
format | email format check failed. |
enum | Value not in the select / radio options list. |
upload_shape | Upload field value isn’t a well-formed FileRef (missing file_id / url / mime / size, or wrong types). |
file_too_large | Uploaded file exceeded the field’s maxSizeMb cap. The resume validator double-checks this against the descriptor’s size so a tampered upload still gets rejected. |
mime_not_allowed | Uploaded MIME type didn’t match the field’s accept allow-list. |
upload_shape, file_too_large and mime_not_allowed will normally surface
at upload time first (your integrator proxy → admin upload endpoint, see
Lifecycle § Step 3 with uploads).
Treat their appearance during resume as a bug in your upload proxy — the
widget tampered with the descriptor, or the proxy returned a descriptor for
a file that exceeded the form’s limits.
Errors from the upload endpoint
These come from the admin-side POST /api/v1/forms/uploads (called by your
integrator proxy, not by the widget directly). HTTP and error follow the
same envelope as the public chat surface.
| HTTP | error | Cause | What to do |
|---|
| 400 | invalid_input | Missing file part or no auth context | Fix the proxy. The chat surface never produces this. |
| 400 | upload_failed | UploadedFile::isValid() reported a PHP upload error (truncated, exceeded upload_max_filesize, etc.) | Surface “upload failed, try again”. |
| 413 | file_too_large | File exceeded the per-field maxSizeMb (or the 100 MB hard cap if no field constraint) | Show the per-field limit. No retry without a smaller file. |
| 415 | mime_not_allowed | File MIME outside the field’s accept list | Show “this field accepts X”. No retry without a different file. |
| 404 | not_found | Serve-route request for an unknown file_id, or for one that belongs to another tenant | Drop the cached descriptor; re-upload. |
| 500 | upload_failed | Underlying storage failure (Flysystem write failed) | Backoff + retry once. |
Engine API errors
These come from the server-to-server Engine API under /api/v1/engine/*
(intent discovery, chat triggers, execution resume) and the deferred
conversions endpoint POST /api/v1/insights/conversions. They use the same
{error, message, details} envelope as the chat surface. The Engine API is
authenticated by one deploy-wide ENGINE_API_TOKEN (engine_api firewall);
tenant_id rides in the request body, so it is a trusted backend channel,
not a browser-facing one.
| HTTP | error | Endpoint(s) | Cause |
|---|
| 400 | invalid_input | all | Body is not a JSON object, or a required field (tenant_id, event_name, intent_name, goal) is missing/empty. GET /intents also returns this when the tenant_id query parameter is absent. |
| 422 | invalid_input | /triggers/chat, /executions/{id}/resume | The optional variables map is malformed — not an object, a list, over 50 keys, over 4096 bytes, a key that isn’t snake_case [a-z][a-z0-9_]{0,63}, or a value nested deeper than 4 levels. details.key names the offending key. |
| 422 | invalid_input | /executions/{id}/resume | Submitted input.values failed the waiting node’s JSON-Schema. details.validation_errors lists per-field reasons (see Validation rule codes). |
| 404 | intent_not_matched | /triggers/chat | No published flow matches the requested intent_name. details.available_intents lists the intent names that are published for the tenant. |
| 404 | execution_not_found | /executions/{id}/resume | No execution with that id (wrong id, or it belongs to another tenant). |
| 409 | invalid_wait_token | /executions/{id}/resume | wait_token is missing, doesn’t match, the execution isn’t in waiting_input status, or a concurrent resume was detected. |
| 409 | idempotency_conflict | /triggers/chat, /executions/{id}/resume | The Idempotency-Key header was reused with a different payload. Replays with the same payload return the cached response instead. |
| 410 | execution_aborted | /executions/{id}/resume | The run was cancelled server-side (admin action / timeout). Drop the pending wait and start a fresh trigger. |
| 404 | unknown_goal | POST /insights/conversions | No active goal exists for the supplied goal code on that tenant. |
| 422 | validation_failed | POST /insights/conversions | conversation_id was supplied but is not a UUID. |
| 5xx | internal_error | all | Server fault (flow version no longer loadable, execution disappeared mid-run, etc.). Backoff and retry per the Retry algorithm. |
Idempotency replaysOn /triggers/chat and /executions/{id}/resume, send a stable
Idempotency-Key header to make a call safe to retry. A replay with the
same body returns the cached status + body (24h TTL). Only a replay whose
body differs from the first call gets 409 idempotency_conflict — fix
your key generation so distinct requests use distinct keys.
The intent catalog at GET /api/v1/engine/intents is cacheable: it returns a
weak ETag and cache_max_age_seconds: 300. Send If-None-Match to get a
304 Not Modified (no error envelope) when the catalog is unchanged.
Flow analytics errors
The read-only analytics endpoints under /api/v1/analytics/* are
session-authenticated (ACL comerix.insights.analytics) and derive the
tenant from the logged-in user, not the body.
| HTTP | error | Endpoint(s) | Cause |
|---|
| 400 | invalid_input | /analytics/failures-by-node | The required flow_id query parameter is missing or empty. |
| 403 | — | all | Caller lacks comerix.insights.analytics, or no tenant is bound to the session. Returned as the framework’s access-denied response, not the JSON envelope above. |
Knowledge-base / Training API errors
These come from the external-crawler surface under /api/v1/training/*
(GET /queue, POST /sources/{id}/claim, /progress, /result). It shares
the engine_api firewall and the {error, message, details} envelope; the
crawler takes tenant_id from the request body. The {id} path segment must
be a 36-char UUID or the route does not match.
| HTTP | error | Endpoint(s) | Cause |
|---|
| 400 | invalid_input | claim, progress, result | Body is not a JSON object, tenant_id is missing or not a UUID, the source id is not a valid UUID, or result was called without a status. |
| 422 | invalid_input | progress, result | pages_indexed / pages_total were present but not integers, or pages_indexed is required on progress and was absent. |
| 404 | not_found | claim, progress, result | No training source with that id belongs to the tenant. |
| 409 | not_claimable | claim | The source is not in a claimable state (already crawling, etc.). details.status carries its current state. |
| 409 | not_crawling | progress, result | The source must be in the crawling state first — claim it before reporting progress or a result. details.status carries its current state. |
| 422 | invalid_status | result | The terminal status was neither ready nor failed. details.status echoes the rejected value. |
| 422 | invalid_counts | progress, result | A page count was negative, or pages_indexed exceeded pages_total. |
Result is idempotentReporting a result whose status already matches the source’s current
state is a no-op that returns 200 with the unchanged source payload — a
retried report after a network blip is safe.
These come from the public Forms API under /api/public/v1/forms/{codeOrId}
(GET for the metered schema, GET /schema for the
cacheable schema document,
POST /submissions to submit, POST /uploads for file fields). It is
authenticated by a public gateway key (via ?publicKey=, the
X-Comerix-Public-Key header, or — except on GET /schema — a publicKey
body field) whose form scope must cover the requested form. CORS + per-IP
rate limiting are applied at the gateway edge, the same way as the chat
surface.
| HTTP | error | Endpoint(s) | Cause |
|---|
| 400 | invalid_input | all | No public key supplied; or on POST /submissions the body is not a JSON object; or on POST /uploads the field query parameter or the multipart file part is missing. |
| 403 | widget_disabled | all | Key not found, disabled, or has no tenant. |
| 403 | form_not_allowed | all | The key’s form scope does not permit this codeOrId. |
| 404 | form_not_found | both GETs, POST /submissions, POST /uploads | Form does not exist, is not embeddable, or (on the GETs) has no public schema. |
| 403 | not_submittable | POST /submissions | The form is not currently accepting submissions. |
| 422 | rejected | POST /submissions | The submission was flagged as spam (honeypot / anti-spam). |
| 422 | invalid_values | POST /submissions | One or more values failed the form’s field rules. details is a list of { field, message } objects, one per failing field. |
| 404 | field_not_found | POST /uploads | The named field is not an upload field on this form. |
| 413 | file_too_large | POST /uploads | File exceeded the field’s size cap. |
| 415 | mime_not_allowed | POST /uploads | File MIME type is outside the field’s accept allow-list. |
| 400 | upload_failed | POST /uploads | The upload otherwise failed (PHP upload error, storage write failure). |
Submission success is 201 / 200A new submission returns 201 Created; a duplicate (matched by
clientSubmissionId) returns 200 OK with the existing
submissionId and status. Neither is an error.
Field rules are enforced server-sideEvery rule a field declares — required, min/max length, word count, regex
pattern, e-mail/URL format and domain policy, numeric range/step, date
window, choice count, consent and rating/file count — is re-checked on
POST /submissions, so a client that skips the matching HTML5 / JavaScript
check still gets a 422 invalid_values. The GET schema’s per-field
validation object mirrors these rules for the renderer, minus the
server-only ones (blocked-word lists, free/disposable mailbox blocking),
which are never disclosed to a visitor but are still enforced.
Public read API errors
These come from the anonymous, cacheable GET endpoints
/api/public/v1/chat/quick-questions and /api/public/v1/config/{section}
(see Public read API). Same envelope, same key
authentication and rate limits as the rest of the public surface. The third
cacheable read, GET /api/public/v1/forms/{codeOrId}/schema, uses the
Public Forms codes above instead (form_not_allowed, form_not_found).
| HTTP | error | Endpoint(s) | Cause |
|---|
| 400 | invalid_input | all | No publicKey query parameter and no X-Comerix-Public-Key header. |
| 403 | widget_disabled | all | Key not found, disabled, or has no tenant. |
| 403 | origin_not_allowed | all | Browser Origin outside the key’s allow-list. |
| 404 | not_found | all | The published document can’t be resolved — for /config/{section}: an unknown section, or one with no publicly readable fields (deliberately indistinguishable). Treat as “not exposed for this tenant”; don’t retry blindly. |
| 429 | rate_limited | all | Per-IP or per-key budget exceeded — honor Retry-After. |
A 304 Not Modified reply to a conditional request (If-None-Match) is
not an error — it means your cached copy is still current; keep using it.
Configuration read API errors
These come from the authenticated, token-protected endpoints
GET /api/v2/config and GET /api/v2/config/{path} (see
Configuration read API). The envelope is the compact
{error, message} shape; authentication failures happen before routing and
carry no envelope.
| HTTP | error | Cause |
|---|
| 400 | invalid_scope | The scope selector is malformed — not global, organization:<uuid>, or tenant:<uuid>. |
| 401 | — | Missing, invalid, expired, or revoked personal access token. |
| 403 | — | The token lacks the api:full scope, or its owner is not an admin. |
| 403 | scope_denied | The scope is well-formed but not the caller’s to read — a non-superadmin may only read their own workspace scope. |
| 404 | not_found | The path cannot be read: unknown, not declared api_readable, secret, or in a section the caller may not view. The four cases are deliberately indistinguishable — don’t probe paths; list GET /api/v2/config instead. |
invalid_scope, scope_denied, and not_found are deterministic — never
retry them.
MCP gateway errors
The MCP gateway at POST /mcp speaks JSON-RPC 2.0, so its errors do not
use the {error, message, details} envelope. Failures come back as a JSON-RPC
error object on an HTTP 200 response (per the JSON-RPC spec — the transport
succeeded even though the call failed):
{
"jsonrpc": "2.0",
"id": "<request id, or null>",
"error": {
"code": -32602,
"message": "<human description>",
"data": { /* optional, code-specific */ }
}
}
Drive your handling off the integer error.code. Authentication itself is
handled upstream by the mcp firewall (a scoped personal access token); a
JSON-RPC error never carries the token check beyond -32002.
Standard JSON-RPC codes
| Code | Meaning | When |
|---|
-32700 | Parse error | The request body was not valid JSON. |
-32600 | Invalid request | Not a JSON-RPC request object: empty, a batch (batches are not supported), jsonrpc not "2.0", missing/empty method, or an id that is not a string/number/null. |
-32601 | Method not found | No handler serves the requested method. |
-32602 | Invalid params | params is not an object; or for tools/call, the name is missing/empty or arguments is not an object, or the arguments failed the tool’s input schema; or for resources/read, the uri is missing or unknown. |
-32603 | Internal error | An unexpected server fault. Details are logged server-side and never leaked to the caller. |
MCP-specific codes
| Code | Symbol | Meaning |
|---|
-32001 | TOOL_NOT_FOUND | tools/call named a tool that no provider owns. data.tool echoes the name. |
-32002 | AUTHORIZATION_DENIED | The request was unauthenticated, or the token’s scope / the user’s ACL does not permit the requested tool. |
-32003 | RATE_LIMITED | Reserved for the per-token rate limit. |
Tool business errors are not JSON-RPC errorsA failure inside a tool (a bad lookup, a downstream error) is returned as
a normal result with isError: true, not a JSON-RPC error. Only
protocol- and authorization-level problems produce an error object.
Reserve -32xxx handling for the transport; inspect result.isError for
tool outcomes.
Notifications get no bodyA JSON-RPC notification (a request with no id) never receives a response
body — the gateway returns an empty 202 Accepted. That includes the
notification case of a method-not-found or handler failure: no error is
returned, because no response is owed.
Retry algorithm
on response:
case 2xx → done
case 401 (once) → re-open session, retry once
case 408|429|5xx → backoff(attempt) and retry, up to 3 attempts
case other 4xx → surface to user / log, don't retry
backoff(n) — exponential with jitter:
base = 500ms
delay = min(base * 2^n + random(0, 250), 8s)
What never to retry
invalid_input — deterministic. Fix the payload.
widget_disabled / origin_not_allowed — config issue, won’t change with
retries.
intent_not_matched — flow unpublished. Retrying immediately won’t help;
poll /sessions again after a delay if you want to recover automatically.
validation_failed — user has to fix inputs first.
What CORS rejections look like
A request from a forbidden origin gets 403 origin_not_allowed with no
Access-Control-Allow-Origin header. Browsers therefore won’t expose the
body to JS — the widget code sees a generic network failure or “fetch
failed”. This is deliberate: an attacker probing from a wrong origin can’t
learn whether a publicKey even exists.
Server-side audit log still records the attempt with the offending origin.