Errors
Verified against backend/internal/publicapi/errors.go and webhook_error_class.go.
The error envelope
Every non-2xx response from /api/v1/ext/* has the same shape — an error object, not a flat string. This deliberately matches the convention OpenAI and Anthropic use, because that's the shape a generated SDK and a model writing client code both reach for first:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "phone_number_id is required",
"hint": "",
"fields": [
{ "field": "phone_number_id", "reason": "is required" }
]
}
}| Field | Meaning |
|---|---|
code | Machine-readable, stable — switch on this |
message | Human-readable, may be reworded over time — don't switch on this |
hint | What to do next, when there's something useful to say |
fields | Present only for validation failures. Names the JSON field you sent (phone_number_id), never an internal Go field or struct name |
details | Present on some errors (e.g. INSUFFICIENT_SCOPE includes your_scopes) |
Field names are yours, not ours
Validation errors name the field the way you sent it. Earlier versions of this API returned the Go validator's raw output:
Key: 'BindPhoneNumberRequest.PhoneNumberID' Error:Field validation for 'PhoneNumberID' failed on the 'required' tag— a Go type name, Go field name, and struct-tag name, handed to a caller who sent JSON and has never seen any of those three symbols. That's fixed: every field error is now translated back to the JSON key you actually used.
Error codes — the complete set
Every code the public surface can return: 53, read out of the handlers and middleware behind /api/v1/ext on origin/main (2026-08-25). code is stable; message and hint may be reworded. Where one code is used with two statuses, both are listed. An earlier version of this page listed seven and called the set open — it is closed, and this is it.
Authentication and scopes (middleware/api_key_auth.go)
| Code | Status | Meaning |
|---|---|---|
MISSING_API_KEY | 401 | No X-API-Key and no Authorization: Bearer header |
INVALID_API_KEY_FORMAT | 401 | The value doesn't look like a key (vm_live_ + 32 characters) |
INVALID_API_KEY | 401 | Well-formed, but matches no key on record |
API_KEY_REVOKED | 401 | The key was revoked in the dashboard |
API_KEY_EXPIRED | 401 | Past its expires_at |
NOT_AUTHENTICATED | 401 | A scoped route was reached with no authenticated key. You should never see this on /ext; if you do, report it |
INSUFFICIENT_SCOPE | 403 | Valid key, missing the scope the route requires — details.required_scope names it, details.your_scopes lists what the key has. Scopes are fixed at creation: mint a new key, you can't widen this one |
Versioning (middleware/api_version.go)
| Code | Status | Meaning |
|---|---|---|
INVALID_API_VERSION | 400 | VoiceMatrix-Version isn't a date |
UNSUPPORTED_API_VERSION | 400 | A date this server doesn't serve — refused rather than silently replaced |
Rate limits and quotas
| Code | Status | Meaning |
|---|---|---|
RATE_LIMIT_EXCEEDED | 429 | Over your tier's request rate — honour Retry-After / X-RateLimit-Reset |
RATE_LIMIT_UNAVAILABLE | 503 | The limiter's own counter store is unreachable — fails closed rather than letting requests through unchecked |
DAILY_CALL_LIMIT_EXCEEDED | 429 | POST /calls past the key's daily_call_limit (50 by default) |
QUOTA_UNAVAILABLE | 503 | The outbound-minutes quota store couldn't answer — if spend can't be accounted for, it isn't spent |
Idempotency and request body (middleware/idempotency.go)
| Code | Status | Meaning |
|---|---|---|
BODY_TOO_LARGE | 413 | Request body over the size cap |
IDEMPOTENCY_KEY_IN_FLIGHT | 409 | A request with this Idempotency-Key is still being processed — wait, then retry |
IDEMPOTENCY_KEY_REUSED | 422 | Same Idempotency-Key, different body — a key is bound to the first body it saw |
IDEMPOTENCY_UNAVAILABLE | 503 | The idempotency store couldn't take the claim — fails closed |
Generic
| Code | Status | Meaning |
|---|---|---|
VALIDATION_ERROR | 400 | A field failed validation — fields[] names each one in your JSON vocabulary |
INVALID_REQUEST | 400 · 422 | Body isn't valid JSON or couldn't be read (400); an explicit empty prompt on an agent write (422) |
NOT_FOUND | 404 | No such resource in your organization |
ORGANIZATION_REQUIRED | 403 | The key is reseller-scoped, with no single organization to write into — a write would have to guess the tenant, so it refuses |
INTERNAL_ERROR · DB_ERROR · CREATE_FAILED · UPDATE_FAILED | 500 | Ours. Retry with the same Idempotency-Key; if it repeats, [email protected] with the timestamp and path |
Agents (publicapi/agents_handler.go, agents_write_handler.go)
| Code | Status | Meaning |
|---|---|---|
INVALID_STATUS | 400 | Unknown status filter on GET /agents |
INVALID_MODEL | 400 | model not one of azure-realtime, gemini-realtime, openai-direct |
INVALID_WEBHOOK_CONFIG | 400 | The agent's pre-call enrichment webhook block failed validation |
INVALID_IF_MATCH | 400 | If-Match must be the integer agent version |
VERSION_CONFLICT | 412 · 409 | If-Match doesn't match the current version (412); or the row changed between read and write (409). Re-read, then retry |
NO_CLIENT_FOR_ORGANIZATION | 409 | The organization has no client record, so an agent can't be created under it |
DEFAULT_CLIENT_UNUSABLE | 409 | The organization's default client record is not usable — needs a data fix by support; retrying won't help |
AGENT_NOT_PUBLISHABLE | 422 | POST /agents/:id/publish on an agent with no usable prompt |
Calls and leads (calls_handler.go, leads_handler.go)
| Code | Status | Meaning |
|---|---|---|
INVALID_PHONE | 400 | to isn't a valid phone number |
AGENT_NOT_FOUND | 400 | agent_id isn't an agent you can use — a 400, not a 404, because the request is what's wrong |
CALL_FAILED | 500 | The call couldn't be created on the telephony side |
Knowledge (knowledge_handler.go)
| Code | Status | Meaning |
|---|---|---|
KNOWLEDGE_UNAVAILABLE | 503 | Ingestion is off because the embedding service isn't configured on this deployment |
KNOWLEDGE_SOURCE_EXISTS | 409 | A source with this title already exists on the agent — delete it first, or pick another title |
KNOWLEDGE_REJECTED | 422 | The content guard refused the document (prompt-injection patterns, English or Hebrew) |
Phone numbers (phonenumbers_handler.go, phonenumbers_acquire*.go)
| Code | Status | Meaning |
|---|---|---|
ACQUISITION_DISABLED | 503 | Self-serve acquisition is switched off on this deployment |
SUBSCRIPTION_REQUIRED | 402 | Acquisition needs a paid plan |
COUNTRY_NOT_SUPPORTED | 400 | No self-serve inventory for that country |
ORG_NUMBER_LIMIT_REACHED | 409 | The organization already holds its included number |
PLATFORM_NUMBER_CAPACITY_REACHED | 402 | The platform's self-serve pool is exhausted |
NO_NUMBERS_AVAILABLE | 503 | Pool empty and the provider returned nothing — contact support; the failure is ours |
PROVIDER_ERROR · PROVIDER_PURCHASE_FAILED | 502 | The carrier refused or failed the purchase |
PHONE_NUMBER_ALREADY_BOUND | 409 | Bound to another agent — unbind it first |
Webhooks (webhooks_handler.go)
| Code | Status | Meaning |
|---|---|---|
INVALID_EVENT | 400 | An events entry isn't in the 5-event catalog |
INVALID_WEBHOOK_URL | 400 | The url failed the SSRF guard (private/internal address) or isn't a URL |
INVALID_AGENT | 400 | agent_id isn't an agent in your organization |
Call control (callcontrol_handler.go)
| Code | Status | Meaning |
|---|---|---|
TEARDOWN_UNAVAILABLE | 503 | Hangup requested but the media layer (LiveKit) isn't configured on this deployment |
TEARDOWN_FAILED | 502 | The media layer refused the hangup — the response's teardown_ok is false |
A known rough edge
A 404 (route or resource not found — e.g. a typo'd webhook URL, or an ID that doesn't exist) currently classifies the same way as a 400/422 (bad payload) in the webhook delivery error-class system described below. That means a customer with a typo in their webhook destination URL gets pointed at their payload, not their URL, when a delivery bounces. Stated here because it's real and unresolved, not because it's been fixed.
Webhook delivery error classes
Separately from the request/response error envelope above, every webhook delivery attempt that doesn't succeed is classified into one of these, visible on the delivery-log entry (GET /webhooks/:id/deliveries). The classes exist because "failed" isn't diagnosable, and four very different situations were all landing in one bucket before this existed.
Ours — something on VoiceMatrix's side of the wire:
| Class | Meaning |
|---|---|
ssrf_blocked | Our own outbound-dial guard refused the resolved destination address |
dns | The destination hostname doesn't resolve |
connect | TCP refused / reset / unreachable |
tls | Handshake or certificate failure |
timeout | No response within the delivery timeout |
redirect_blocked | A redirect hop failed re-validation |
config | The webhook itself is unusable (disallowed HTTP method, bad content type) |
Theirs — the destination answered, and said no:
| Class | HTTP status | Meaning |
|---|---|---|
auth | 401 | Destination rejected our credentials |
authz | 403 | Destination rejected the request as forbidden |
validation | 400, 404, 422 | Destination rejected the payload (or — see the rough edge above — the URL doesn't exist) |
conflict | 409 | Destination says "already exists" — this is usually the integration working correctly, not failing. In one production measurement this was the single largest class (46 of 119 non-success deliveries), and every one was a destination correctly refusing a duplicate registration |
rate_limit | 408, 429 | Destination asked us to slow down |
upstream_4xx / upstream_5xx | other 4xx/5xx | Uncategorized client/server error from the destination |
internal covers anything that doesn't match a known pattern — an honest "we don't know" rather than silently absorbing it into an unrelated bucket.
Why this distinction matters in practice: a real production incident had 43 deliveries fail with no response at all. The customer-facing story would have been "your endpoint is broken" — the actual cause was VoiceMatrix's own reachability guard declining to dial the address. Those 43 counted toward the auto-pause threshold and left two customer webhooks paused with the wrong diagnosis attached. The class field is what lets the UI (and you) tell "your server is down" apart from "we wouldn't call your server."
Next
- Webhooks — delivery, retries, replay, and where these classes surface
- Authentication — scopes and the requests that produce
INSUFFICIENT_SCOPE