Skip to main content
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

HTTPerrorEndpoint(s)CauseWidget response
400invalid_inputallBody malformed or required field missingFix the payload — bug in the widget. No retry.
401invalid_session_token/messages, /eventsToken expired, tampered, or absentSilently re-open /sessions, retry the original request once. If 401 again, escalate.
403widget_disabledallWidget key not found or admin disabled itStop. Show a “chat unavailable” state; the tenant admin must intervene.
403origin_not_allowedallRequest Origin not in the key’s allowedOriginsStop. Misconfiguration — get the integrator’s domain added to the key.
403intent_not_allowed/messages (trigger)Requested intentName is outside the widget key’s allow-listBug in the widget — only use names from session.intents[*].name. details.allowed_intents lists what’s permitted.
404intent_not_matched/messages (trigger)No published flow for the requested intentShow “chat is being updated, try again soon” + report to ops.
404execution_not_foundGET /executions/{id}, /messages (resume)Wrong id, or the execution belongs to a different tenant or to a different conversation than your sessionBug in the widget — only poll/resume executionId values returned by /messages within the same session.
409invalid_wait_token/messages (resume)Wait token expired, mismatched, or already usedDrop your cached waitToken + executionId, ask the user what they want, send a fresh trigger with text.
410execution_aborted/messages (resume)Server-side cancelled the run (admin action / timeout)Same as 409 — drop the pending wait, start fresh.
422validation_failedallField length / pattern / JSON Schema rejectionHighlight the offending fields from details.validation_errors and let the user retry. No backoff — the next call works as soon as inputs are valid.
429rate_limitedallRequest 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.
5xxinternal_errorallServer faultExponential 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

ruleMeaning
requiredField is required but the value was null / empty string / empty array. (Skipped for fields whose visibleIf evaluated to false.)
typeWrong JSON type — e.g. number sent where the schema expected string.
min_length / max_lengthString length outside the field’s bounds.
minimum / maximumNumber outside the field’s range (also used by rating).
patternString did not match the regex on the field.
formatemail format check failed.
enumValue not in the select / radio options list.
upload_shapeUpload field value isn’t a well-formed FileRef (missing file_id / url / mime / size, or wrong types).
file_too_largeUploaded 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_allowedUploaded 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.
HTTPerrorCauseWhat to do
400invalid_inputMissing file part or no auth contextFix the proxy. The chat surface never produces this.
400upload_failedUploadedFile::isValid() reported a PHP upload error (truncated, exceeded upload_max_filesize, etc.)Surface “upload failed, try again”.
413file_too_largeFile 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.
415mime_not_allowedFile MIME outside the field’s accept listShow “this field accepts X”. No retry without a different file.
404not_foundServe-route request for an unknown file_id, or for one that belongs to another tenantDrop the cached descriptor; re-upload.
500upload_failedUnderlying 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.
HTTPerrorEndpoint(s)Cause
400invalid_inputallBody 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.
422invalid_input/triggers/chat, /executions/{id}/resumeThe 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.
422invalid_input/executions/{id}/resumeSubmitted input.values failed the waiting node’s JSON-Schema. details.validation_errors lists per-field reasons (see Validation rule codes).
404intent_not_matched/triggers/chatNo published flow matches the requested intent_name. details.available_intents lists the intent names that are published for the tenant.
404execution_not_found/executions/{id}/resumeNo execution with that id (wrong id, or it belongs to another tenant).
409invalid_wait_token/executions/{id}/resumewait_token is missing, doesn’t match, the execution isn’t in waiting_input status, or a concurrent resume was detected.
409idempotency_conflict/triggers/chat, /executions/{id}/resumeThe Idempotency-Key header was reused with a different payload. Replays with the same payload return the cached response instead.
410execution_aborted/executions/{id}/resumeThe run was cancelled server-side (admin action / timeout). Drop the pending wait and start a fresh trigger.
404unknown_goalPOST /insights/conversionsNo active goal exists for the supplied goal code on that tenant.
422validation_failedPOST /insights/conversionsconversation_id was supplied but is not a UUID.
5xxinternal_errorallServer 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.
HTTPerrorEndpoint(s)Cause
400invalid_input/analytics/failures-by-nodeThe required flow_id query parameter is missing or empty.
403allCaller 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.
HTTPerrorEndpoint(s)Cause
400invalid_inputclaim, progress, resultBody 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.
422invalid_inputprogress, resultpages_indexed / pages_total were present but not integers, or pages_indexed is required on progress and was absent.
404not_foundclaim, progress, resultNo training source with that id belongs to the tenant.
409not_claimableclaimThe source is not in a claimable state (already crawling, etc.). details.status carries its current state.
409not_crawlingprogress, resultThe source must be in the crawling state first — claim it before reporting progress or a result. details.status carries its current state.
422invalid_statusresultThe terminal status was neither ready nor failed. details.status echoes the rejected value.
422invalid_countsprogress, resultA 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.

Public Forms API errors

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.
HTTPerrorEndpoint(s)Cause
400invalid_inputallNo 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.
403widget_disabledallKey not found, disabled, or has no tenant.
403form_not_allowedallThe key’s form scope does not permit this codeOrId.
404form_not_foundboth GETs, POST /submissions, POST /uploadsForm does not exist, is not embeddable, or (on the GETs) has no public schema.
403not_submittablePOST /submissionsThe form is not currently accepting submissions.
422rejectedPOST /submissionsThe submission was flagged as spam (honeypot / anti-spam).
422invalid_valuesPOST /submissionsOne or more values failed the form’s field rules. details is a list of { field, message } objects, one per failing field.
404field_not_foundPOST /uploadsThe named field is not an upload field on this form.
413file_too_largePOST /uploadsFile exceeded the field’s size cap.
415mime_not_allowedPOST /uploadsFile MIME type is outside the field’s accept allow-list.
400upload_failedPOST /uploadsThe 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).
HTTPerrorEndpoint(s)Cause
400invalid_inputallNo publicKey query parameter and no X-Comerix-Public-Key header.
403widget_disabledallKey not found, disabled, or has no tenant.
403origin_not_allowedallBrowser Origin outside the key’s allow-list.
404not_foundallThe 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.
429rate_limitedallPer-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.
HTTPerrorCause
400invalid_scopeThe scope selector is malformed — not global, organization:<uuid>, or tenant:<uuid>.
401Missing, invalid, expired, or revoked personal access token.
403The token lacks the api:full scope, or its owner is not an admin.
403scope_deniedThe scope is well-formed but not the caller’s to read — a non-superadmin may only read their own workspace scope.
404not_foundThe 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

CodeMeaningWhen
-32700Parse errorThe request body was not valid JSON.
-32600Invalid requestNot 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.
-32601Method not foundNo handler serves the requested method.
-32602Invalid paramsparams 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.
-32603Internal errorAn unexpected server fault. Details are logged server-side and never leaked to the caller.

MCP-specific codes

CodeSymbolMeaning
-32001TOOL_NOT_FOUNDtools/call named a tool that no provider owns. data.tool echoes the name.
-32002AUTHORIZATION_DENIEDThe request was unauthenticated, or the token’s scope / the user’s ACL does not permit the requested tool.
-32003RATE_LIMITEDReserved 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.