Guides

Errors

Every failure is a machine-readable code and a sentence for a human. Branch on the code; show the sentence.

The envelope

The status tells you the class of problem and whether to retry. The code tells you which problem it is, and is the only part safe to write logic against, messages are rewritten for clarity, codes are not.

Any 4xx or 5xx
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": [
{
"field": "contacts[0].phoneNumber",
"message": "Invalid phone number. Use E.164 format, e.g. +14155551234"
}
]
}
}

details appears on validation failures and is per-field. It is what you show a user: “Invalid request body” tells them nothing, and the field message tells them exactly what to fix.

Codes

14 codes
401INVALID_API_KEY
No key on the request, or one that does not exist.Check the header name and that the variable is set where the code runs.
401EXPIRED_API_KEY
The key was revoked or has passed its expiry.Issue a new key and rotate it in.
403INSUFFICIENT_SCOPE
A valid key, refused for this endpoint. The message names the capability it wanted.Scopes are fixed at creation (issue a new key rather than editing this one.
404NOT_FOUND
No such resource) or one that exists in another organization, which answers the same way.Check the id, then check the key belongs to the organization that owns it.
400VALIDATION_ERROR
The body is the wrong shape. Every offending field is named in details.Read details rather than the message: it is field-by-field.
400SCHEMA_MISMATCH
Contacts do not match the contact list's declared schema.Fetch the list and compare field names and types before importing.
400INVALID_FILE_FORMAT
A file import that is not valid JSON or CSV, or is malformed part way through.Validate the file locally; a truncated upload fails here rather than at the row.
400FILE_DOWNLOAD_FAILED
The file URL could not be fetched.The URL must be reachable from the public internet and live long enough to be read.
409CONFLICT
The request fights the current state, a duplicate, or a transition that is not allowed.Re-read the resource; something else has probably already done this.
409IDEMPOTENCY_IN_PROGRESS
A request with this Idempotency-Key is still running.Wait for Retry-After and send the same request again; you get the first result once it finishes.
422IDEMPOTENCY_KEY_REUSED
This Idempotency-Key was already used for a different request.Use a new key for each distinct request. A key belongs to one method, path and body.
429RATE_LIMIT_EXCEEDED
Too many requests on this key.Obey Retry-After, then back off exponentially.
500INTERNAL_ERROR
Ours, not yours.Retry once; if it persists, send us the time and the request id.
503SERVICE_UNAVAILABLE
A dependency is briefly unavailable, so the request was not run.Obey Retry-After and send it again.

Handling them

Three groups, and they want different things: retry with a wait (429, 5xx), fix the request and do not retry (400, 409), or fix the credentials (401, 403).

import-contacts.js
async function importContacts(listId, contacts) {
const response = await fetch(
`https://vocalis.io/api/v1/contact-lists/${listId}/contacts`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VOCALIS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ contacts }),
},
);
if (response.ok) return response.json();
const { code, message, details } = (await response.json()).error;
switch (code) {
case 'RATE_LIMIT_EXCEEDED': {
const wait = Number(response.headers.get('Retry-After') ?? 5);
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
return importContacts(listId, contacts);
}
case 'VALIDATION_ERROR':
case 'SCHEMA_MISMATCH':
// Not worth retrying: the rows are wrong, not the moment.
throw new BadRows(details);
case 'INVALID_API_KEY':
case 'EXPIRED_API_KEY':
case 'INSUFFICIENT_SCOPE':
throw new Misconfigured(message);
default:
throw new Error(message);
}
}

Log the whole envelope

Keep code and details, not just the message. A support conversation about an import that half-worked is five minutes with them and an afternoon without.

Failures on a key's credentials are covered in authentication; the retry budget is in rate limits.

Retrying safely

A request that times out may still have run. For a read that does not matter; for a POST it can mean a second contact list, or a second phone call to the same person. Send an Idempotency-Key header on any POST and the API runs it at most once: every repeat with the same key gets the first result back instead of running again.

curl -X POST "https://vocalis.io/api/v1/campaigns/{campaignId}/trigger" \
-H "Authorization: Bearer $VOCALIS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reminder-appt-48213" \
-d '{ "phoneNumber": "+14155551234" }'
The key
Any 1 to 255 printable ASCII characters. Derive it from the thing you are doing, an appointment id or a job id, so a rerun of the same job sends the same key.
Scope
Keys are scoped to the API key that sent them. Two integrations choosing the same string never see each other's results.
Replays
A repeat gets the stored status and body, with an Idempotent-Replayed: true header so you can tell it apart from a fresh run.Kept for 24 hours after the first request finishes.
What is not stored
5xx, 429, 409 and 401 responses. The failure was about the moment, not the request, so a retry with the same key runs for real.

Two codes are specific to keys. IDEMPOTENCY_IN_PROGRESS (409) means the first request with this key has not finished: wait for Retry-After and send it again. IDEMPOTENCY_KEY_REUSED (422) means the key was already used with a different method, path or body; that is a bug in the caller, so do not retry it.

The SDK does this for you

The TypeScript SDK sends a key on every POST and reuses it across its own retries. Pass idempotencyKey to keep the same key across yours.