The envelope
Every error from /v1, /mcp (before the JSON-RPC layer), /v1/api-keys and the consent endpoints
has this shape, with Content-Type: application/json and Cache-Control: no-store. Every field is always
present (null when absent); billing_url appears only on insufficient_credits_error.
message is safe to show to an end user. request_id equals the Request-Id response header.
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
Cache-Control: no-store
Request-Id: req_Fa3rR6ZW8v3AM31xlv9CYQ
VoixCall-Version: 2026-10-01
WWW-Authenticate: Bearer error="invalid_token", resource_metadata="https://api.voixcall.com/.well-known/oauth-protected-resource/v1"
{
"error": {
"type": "authentication_error",
"code": "invalid_token",
"message": "Provide a bearer access token.",
"param": null,
"doc_url": "https://voixcall.com/developers/errors#invalid_token",
"request_id": "req_Fa3rR6ZW8v3AM31xlv9CYQ"
}
} type is the coarse class for your switch; code is the specific condition. Match on
code, treat unknown codes as their type, and never parse message.
Types
| type | Statuses | Meaning |
|---|---|---|
invalid_request_error | 400, 405, 406, 409, 410, 413, 415 | The request is malformed, a parameter is invalid (param names it), the version is unknown or retired, or the body is too large. |
authentication_error | 401 | No usable credential. Also carries WWW-Authenticate with the resource-metadata URL. |
permission_error | 403 | The credential is valid but may not do this: missing scope, IP outside the key's allowlist, a sharing switch the user has not turned on (not_enabled, with settings_url), or a policy check. |
not_found_error | 404 | No such object or route. Objects that belong to another account also return 404. |
insufficient_credits_error | 402 | A paid operation needs more credit than the balance holds: insufficient_credits on POST /v1/calls/{id}/transcript, later call placement. Carries billing_url. |
call_error | 402, 409, 422 | The call cannot be called or acted on: destination_blocked on GET /v1/rates (422); with phase 4, call_too_short (402) and transcript_exists, transcription_in_progress, recording_unavailable (409) on transcript requests. |
idempotency_error | 409 | Idempotency-Key conflicts and other concurrency conflicts. |
rate_limit_error | 429 | A request budget is exhausted. Retry-After says when to retry. |
api_error | 500, 503 | Our fault or an upstream outage (503 rate_unavailable, rate_limiter_unavailable, transcription_unavailable). Retry with the same Idempotency-Key where one applies, then write to support with the request_id. |
Codes: /v1 and /mcp
Each code is an anchor: https://voixcall.com/developers/errors#<code>, which is the doc_url the API sends.
-
invalid_request400 invalid_request_error -
Where: any /v1 operation
Request validation failed; param names the offending field or query parameter and message says what is wrong.
What to do: Fix the named parameter and retry. Also the catch-all for other 4xx conditions without a more specific code.
-
invalid_body400 invalid_request_error -
Where: POST /v1/*, /v1/api-keys, consent decision
The body could not be read or is not the JSON shape the endpoint expects.
What to do: Send valid JSON with Content-Type: application/json and the documented fields.
-
invalid_version400 invalid_request_error -
Where: any /v1 request with a VoixCall-Version header
The header names a version the server does not know. message lists the known versions (currently 2026-10-01).
What to do: Send a known version or omit the header to use your pinned or the current version.
-
version_sunset410 invalid_request_error -
Where: any /v1 request on a retired version
The effective VoixCall-Version passed its Sunset date. message links the migration guide. No version is deprecated today.
What to do: Move to the current version; see the changelog entry for the migration.
-
token_in_query400 invalid_request_error -
Where: /v1, /mcp
An access_token query parameter was present. Tokens in URLs end up in logs, so they are refused before any lookup.
What to do: Send the token only in the Authorization: Bearer header.
-
invalid_token401 authentication_error -
Where: /v1, /mcp, /v1/api-keys, /api/oauth/*
No bearer token, or one that is invalid, expired, revoked, of the wrong kind (a refresh token, an API key at /mcp) or bound to a different resource. The message says which: “Provide a bearer access token.”, “The access token is invalid or expired.”, “Reconnect VoixCall in your assistant.” (the authorization was revoked or the password changed), “The API key is invalid or its account is closed.”, or “Sign in to continue.” on session-only endpoints.
What to do: Refresh the token, or re-run the OAuth flow; for “Reconnect”, the user must connect the app again. The WWW-Authenticate header carries the resource-metadata URL for discovery.
-
insufficient_scope403 permission_error -
Where: every scoped /v1 read (rates:read on /v1/rates, credits:read on /v1/credits/*, calls:read on /v1/calls, contacts:read on /v1/contacts, numbers:read on /v1/numbers and /v1/caller-id), and GET /v1/calls/{id}?expand[]=transcript without transcripts:read (param expand[]). Phase 4 (since 2026-10-04): transcripts:read on GET /v1/calls/{id}/transcript, transcripts:read and calls:place on POST /v1/calls/{id}/transcript, messages:read on GET /v1/messages, messages:history on GET /v1/messages/threads and on a window_minutes over 10 (param window_minutes, challenge scope="messages:history"). /v1/me and voixcall_whoami accept any scope.
The token's scopes do not include the one this operation needs. The scope gate answers with WWW-Authenticate: Bearer error="insufficient_scope", scope="<needed>".
What to do: Ask the user to reconnect with the named scope. Tokens never gain scopes after consent.
-
ip_not_allowed403 permission_error -
Where: /v1 with an API key
The key is valid but the request came from an address outside the key's IP allowlist.
What to do: Call from an allowed address, or edit the key's allowlist.
-
summaries_not_enabled403 permission_error -
Where: Retired: GET /v1/calls/{id}?expand[]=transcript answered it until 2026-10-04
No longer returned. Since phase 4 (2026-10-04) the same request answers 403 not_enabled, and only while the account's “Allow connected apps to read call summaries” switch is off.
What to do: Handle not_enabled instead.
-
not_enabled403 permission_error -
Where: GET /v1/calls/{id}/transcript, POST /v1/calls/{id}/transcript, GET /v1/calls/{id}?expand[]=transcript (param expand[]), GET /v1/messages and GET /v1/messages/threads (param number), voixcall_read_messages
The user has not allowed connected apps to read this: the account switch “Allow connected apps to read call summaries” is off, or the number's switch “Allow connected apps to read messages on this number” is off. The body carries settings_url (https://app.voixcall.com/dashboard?tab=settings#connected-apps), where the user turns it on. Nothing is read. voixcall_get_call_summary reports the same state as a normal result with status not_enabled.
What to do: Tell the user where to turn it on (settings_url). Do not retry until they have; the setting is not something an app can change.
-
transcript_not_found404 not_found_error -
Where: GET /v1/calls/{id}/transcript
The call is yours but has no transcript. message says whether one can be requested and what it would cost.
What to do: With transcripts:read and calls:place, request one with POST /v1/calls/{id}/transcript; otherwise the user can request it in the VoixCall app.
-
insufficient_credits402 insufficient_credits_error -
Where: POST /v1/calls/{id}/transcript
The balance cannot cover the transcript's price. The body carries billing_url. Nothing was created.
What to do: Send the user to billing_url to add credits, then retry with a new Idempotency-Key.
-
call_too_short402 call_error -
Where: POST /v1/calls/{id}/transcript
The recording is too short to transcribe (under one second).
What to do: Nothing to retry for this call.
-
transcript_exists409 call_error -
Where: POST /v1/calls/{id}/transcript
The call already has a transcript: being made, done, retrying automatically after a failure, or removed by the retention period (which cannot be made again).
What to do: Read it with GET /v1/calls/{id}/transcript.
-
transcription_in_progress409 call_error -
Where: POST /v1/calls/{id}/transcript
Another requested transcript on this account is still being made; one at a time per account.
What to do: Retry with a new Idempotency-Key once the other one finishes (usually about a minute).
-
Where: POST /v1/calls/{id}/transcript
The call was not recorded, or its recording has not been saved yet.
What to do: Only recorded calls can be transcribed; if the call just ended, retry in a few minutes.
-
Where: GET and POST /v1/calls/{id}/transcript
Transcription is switched off on the server, or the stored transcript could not be loaded (include=utterances).
What to do: Retry later; the summary without utterances may still load.
-
number_not_found404 not_found_error -
Where: GET /v1/messages, GET /v1/messages/threads, voixcall_read_messages (param number); PUT /api/oauth/sharing/numbers/{id}/messages
No such virtual number on the personal account: unknown, another account's, a team number, released, or (with a vc_test_ key) the test partition, which has no numbers.
What to do: Use a number from GET /v1/numbers, as E.164 or its num_ id.
-
invalid_number400 invalid_request_error -
Where: GET /v1/messages, GET /v1/messages/threads (param number)
number is missing or is neither an E.164 number nor a num_ id.
What to do: Send number=%2B14155550100 or number=num_….
-
invalid_window400 invalid_request_error -
Where: GET /v1/messages (param window_minutes)
window_minutes is not an integer from 1 to 60.
What to do: Use 1–60; the default is 10. Over 10 needs messages:history.
-
forbidden403 permission_error -
Where: /v1 (generic)
A policy check refused the request and no more specific code applies.
What to do: Read message; if it is unexpected, write to support with the request_id.
-
not_found404 not_found_error -
Where: /v1 object reads
No object with that id in the connected account. Objects owned by other accounts look identical to missing ones.
What to do: Check the id and its prefix (usr_, call_, …).
-
call_not_found404 not_found_error -
Where: GET /v1/calls/{id}
No call with that id on your personal account: the id is malformed, belongs to another account or an organization, or (with a vc_test_ key) the test partition has no calls.
What to do: Use an id from GET /v1/calls with the call_ prefix.
-
route_not_found404 not_found_error -
Where: any unknown path under /v1
No such endpoint. message links the OpenAPI document.
What to do: Compare the path and method with the reference. Call placement and webhooks are not live yet.
-
method_not_allowed405 invalid_request_error -
Where: /v1
The path exists but not with this HTTP method.
What to do: Use the method the reference lists for the path.
-
not_acceptable406 invalid_request_error -
Where: /v1
The Accept header excludes application/json.
What to do: Send Accept: application/json or omit Accept.
-
unsupported_media_type415 invalid_request_error -
Where: /v1 POST
The body's Content-Type is not one the endpoint accepts.
What to do: Send Content-Type: application/json.
-
request_too_large413 invalid_request_error -
Where: /v1 (256 KB), /v1/api-keys (16 KB)
The body exceeds the cap for that endpoint.
What to do: Send a smaller body; no legitimate request needs more.
-
invalid_limit400 invalid_request_error -
Where: list endpoints: GET /v1/credits/transactions, /v1/calls, /v1/contacts, /v1/numbers, /v1/api-keys
limit is not an integer between 1 and 100. param is limit.
What to do: Use 1–100; the default is 10.
-
invalid_cursor400 invalid_request_error -
Where: list endpoints: GET /v1/credits/transactions, /v1/calls, /v1/contacts, /v1/numbers, /v1/api-keys
starting_after or ending_before is not a valid id with that list's prefix (txn_, call_, ct_, num_, key_), is not one of your objects, or both cursors were sent. param names the cursor.
What to do: Pass exactly one cursor, using an id from a previous page of the same list.
-
invalid_destination400 invalid_request_error -
Where: GET /v1/rates
to is not a full international number: + and country code followed by 7 to 15 digits that are possible for that country. param is to.
What to do: Send E.164, for example +442071234567, and encode the + as %2B in the URL.
-
destination_blocked422 call_error -
Where: GET /v1/rates
VoixCall does not call this destination (for example premium or fraud-prone number ranges).
What to do: Nothing to retry; the number cannot be called from VoixCall.
-
Where: GET /v1/rates
The price for this number could not be determined right now (the pricing lookup failed upstream).
What to do: Retry after a short back-off; if it persists, write to support with the request_id.
-
invalid_filter400 invalid_request_error -
Where: GET /v1/calls
A filter is invalid: direction is not outbound or inbound, started_at[gte] or started_at[lte] is not an RFC 3339 timestamp, started_at[lte] is before started_at[gte], or to is not a full E.164 number. param names the filter.
What to do: Fix the named filter, for example started_at[gte]=2026-09-01T00:00:00Z or to=%2B442071234567.
-
invalid_query400 invalid_request_error -
Where: GET /v1/contacts
q is shorter than 2 or longer than 64 characters after trimming. param is q. A missing q is invalid_request.
What to do: Send 2 to 64 characters of a name, company or number.
-
invalid_expand400 invalid_request_error -
Where: GET /v1/calls/{id}
expand[] names something other than transcript. param is expand[].
What to do: Use expand[]=transcript or omit expand[].
-
idempotency_key_required400 invalid_request_error -
Where: POST /v1/calls/{id}/transcript and POST /v1/calls (planned)
This operation spends money and requires an Idempotency-Key header.
What to do: Send Idempotency-Key: <UUID v4> and reuse it when retrying the same request.
-
invalid_idempotency_key400 invalid_request_error -
Where: any /v1 POST with the header
Idempotency-Key is empty, longer than 255 characters, or contains non-printable ASCII.
What to do: Use a UUID v4.
-
idempotency_in_progress409 idempotency_error -
Where: any /v1 POST with the header
A request with the same key is still executing.
What to do: Wait a moment and retry with the same key; you will get the stored response.
-
idempotency_key_reused409 idempotency_error -
Where: any /v1 POST with the header
The key was already used within 24 hours with a different method, path or body.
What to do: Use a new key for a new request. Keys are unique per user and credential.
-
conflict409 idempotency_error -
Where: /v1 (generic)
A concurrency conflict without a more specific code.
What to do: Re-read the object and retry.
-
rate_limited429 rate_limit_error -
Where: /v1, /mcp, /v1/api-keys, /.well-known/*, openapi.json
A request budget is exhausted. Retry-After (seconds) and the RateLimit headers say which budget and when it refills. Also returned instead of 401 when a source exceeds 60 failed bearer checks a minute.
What to do: Back off for Retry-After seconds. Budgets are on the rate limits page.
-
Where: /v1, /mcp, /v1/api-keys, /.well-known/*
The rate limiter's store is unreachable; public paths fail closed rather than open. Retry-After: 5.
What to do: Retry after five seconds. If it persists, we are already paged; write to support with the request_id.
-
internal_error500 api_error -
Where: anywhere
Something failed on our side. The message never contains internal detail.
What to do: Retry once; if it persists, write to support with the request_id.
Codes: API-key management (/v1/api-keys, signed-in session only)
These endpoints take the dashboard session, not an API key or OAuth token; the Settings screen that calls them ships later.
-
invalid_name400 invalid_request_error -
Where: POST /v1/api-keys
name is empty or too long. param is name.
What to do: Use 1 to 100 characters.
-
invalid_scopes400 invalid_request_error -
Where: POST /v1/api-keys
scopes is empty or contains an unknown scope. param is scopes.
What to do: List at least one scope from the scope table.
-
invalid_expiry400 invalid_request_error -
Where: POST /v1/api-keys
expires_in_days is outside 1 to 365. param is expires_in_days.
What to do: Omit it for the 90-day default, or choose 1–365.
-
invalid_ip_allowlist400 invalid_request_error -
Where: POST /v1/api-keys
ip_allowlist has too many entries or one that is not an IP address or CIDR. param is ip_allowlist.
What to do: Use valid IPv4/IPv6 addresses or CIDR ranges.
-
api_key_limit409 invalid_request_error -
Where: POST /v1/api-keys
You already have 10 live keys.
What to do: Revoke a key you no longer use, then create the new one.
-
api_key_revoked409 invalid_request_error -
Where: POST /v1/api-keys/{id}/rotate
The key was revoked and cannot be rotated.
What to do: Create a new key.
-
api_key_expired409 invalid_request_error -
Where: POST /v1/api-keys/{id}/rotate
The key has expired and cannot be rotated.
What to do: Create a new key.
-
api_key_not_found404 not_found_error -
Where: /v1/api-keys/{id}
No key with that id in your account.
What to do: List your keys and use an id from the list.
Codes: consent and connections (/api/oauth/*, signed-in session only)
Returned to the consent page on app.voixcall.com and to the Connected apps screen. Listed because their doc_url also points here.
-
transaction_not_found404 not_found_error -
Where: GET/POST /api/oauth/transactions/{id}
The authorization request id does not exist.
What to do: Start the flow again from the client.
-
transaction_binding_failed403 permission_error -
Where: GET/POST /api/oauth/transactions/{id}
The consent page was opened in a different browser from the one that started the flow, or by a different user.
What to do: Go back to the client and start again in the same browser.
-
transaction_decided409 idempotency_error -
Where: GET/POST /api/oauth/transactions/{id}
This authorization request was already allowed or denied.
What to do: Nothing to do; start a new flow if you need another decision.
-
transaction_expired410 invalid_request_error -
Where: GET/POST /api/oauth/transactions/{id}
More than 10 minutes passed before a decision.
What to do: Start the flow again from the client.
-
client_disabled403 permission_error -
Where: GET /api/oauth/transactions/{id}
The client application was disabled by VoixCall.
What to do: Contact support if you believe this is a mistake.
-
confirmation_required400 invalid_request_error -
Where: POST /api/oauth/transactions/{id}/decision
A self-registered client asked for a sensitive scope and the confirmation checkbox was not ticked.
What to do: Tick the confirmation and allow again, or deny.
-
invalid_decision400 invalid_request_error -
Where: POST /api/oauth/transactions/{id}/decision
decision is neither allow nor deny.
What to do: Send {"decision": "allow"} or {"decision": "deny"}.
-
connection_not_found404 not_found_error -
Where: DELETE /api/oauth/connections/{client_id}
No connected app with that client id on this account.
What to do: List connections and use a client_id from the list.
-
password_required400 invalid_request_error -
Where: PUT /api/oauth/sharing/numbers/{id}/messages (turning message sharing on)
The account has a password and none was sent. Turning message sharing on asks for the password again.
What to do: Ask the user for their VoixCall password and send it with {"enabled": true, "password": …}.
-
invalid_password403 permission_error -
Where: PUT /api/oauth/sharing/numbers/{id}/messages
The password is not correct. 403, not 401, so the app does not sign the user out. After 5 wrong passwords in 15 minutes (20 per network address) the endpoint answers 429 rate_limited even with the right one.
What to do: Let the user try again, or wait for Retry-After after too many attempts.
-
reauth_required403 permission_error -
Where: PUT /api/oauth/sharing/numbers/{id}/messages (accounts that sign in with Google or Apple)
The account has no password, and the last sign-in was more than 5 minutes ago.
What to do: Send the user through sign-in again, then turn the switch on within 5 minutes.
OAuth endpoints (/oauth/*)
The token, revocation and registration endpoints answer in the RFC 6749 §5.2 / RFC 7591 shape, not the envelope, because
OAuth clients parse {"error": …}. error_description is present where it helps. No
Request-Id is echoed in these bodies, but the header is still on the response.
{"error": "invalid_grant"}
{"error": "temporarily_unavailable", "error_description": "too many requests; retry after 12 seconds"} Token and revocation: POST /oauth/token, POST /oauth/revoke
invalid_request400-
A required form field is missing (grant_type, code, code_verifier, client_id, redirect_uri, resource on the code grant; refresh_token and client_id on refresh), the body is not form-encoded, or the token to revoke is missing.
What to do: Send every field as application/x-www-form-urlencoded in the body, never in the query string.
invalid_grant400-
The code or refresh token is unknown, expired, already used, issued to another client, bound to a different redirect_uri or resource, or the PKCE verifier does not match. A reused refresh token revokes its whole grant family.
What to do: Start a new authorization. Three failures with one credential revoke it.
invalid_scope400-
A refresh asked for scopes the grant does not hold.
What to do: Omit scope, or request a subset of the granted scopes.
unsupported_grant_type400-
grant_type is not authorization_code or refresh_token.
What to do: Use one of the two supported grants; there are no client-credentials or password grants.
-
429 with Retry-After: this source is backed off after failed exchanges, this grant family refreshed more than 10 times a minute, or the token-endpoint flood guard tripped. 503 with Retry-After: the limiter or the database was unreachable, or (on /oauth/register) registration is closed because too many clients are pending purge.
What to do: Wait Retry-After seconds and retry the same request.
server_error500-
The exchange failed on our side.
What to do: Retry; if it persists, write to support.
/oauth/revoke returns 200 for unknown tokens too (RFC 7009), so it cannot be used to probe.
Authorization: GET /oauth/authorize
A bad client or redirect URI renders an error page and never redirects. Everything else redirects back to the client's
redirect_uri with error, error_description, state and iss (RFC 9207).
| error | Delivered as | Meaning |
|---|---|---|
invalid_client | error page (no redirect) | client_id is unknown, its metadata document could not be fetched or validated, or the client is disabled. |
invalid_request | error page or redirect | On the error page: redirect_uri is not registered for the client. On redirect: state, code_challenge or code_challenge_method is missing, or the method is not S256. |
unsupported_response_type | redirect | response_type is not code. |
invalid_target | redirect | resource is missing, repeated, or not exactly https://api.voixcall.com/v1 or https://api.voixcall.com/mcp. |
invalid_scope | redirect | scope is empty or lists a scope the server does not know. |
access_denied | redirect | The user chose Deny on the consent page. |
server_error | redirect | The authorization request could not be stored. |
Registration: POST /oauth/register
400 with an RFC 7591 error; 503 temporarily_unavailable with Retry-After: 3600 when registration is closed.
| error | Meaning |
|---|---|
invalid_client_metadata | The body is not a JSON object, application_type is not web or native, token_endpoint_auth_method is not none, grant_types or response_types include unsupported values, client_name exceeds 100 characters, or client_uri is not an http(s) URL. |
invalid_redirect_uri | More than 10 redirect_uris, or one that is not an https URL or an http loopback URL, or has a fragment, userinfo or wildcard; web clients may register https only. |
429 and 503 on /oauth/*
Rate limits and lockouts on the OAuth routes answer 429 {"error": "temporarily_unavailable"} with
Retry-After and the RateLimit headers; RFC 6749 has no rate-limit code and invalid_request
would wrongly tell a compliant client its request was malformed. When the limiter's store is unreachable the same routes answer
503 {"error": "temporarily_unavailable"} with Retry-After: 5. Budgets are on the
rate limits page.
Errors inside MCP
HTTP-level failures on /mcp (401, 403, 429, 503) use the envelope above. Once a request is authenticated, tool
failures come back as MCP results with isError: true and a plain sentence such as “Not connected. Reconnect
VoixCall in your assistant.” or “VoixCall is temporarily unavailable. Try again.”; JSON-RPC errors are reserved for unknown
tools and malformed arguments.