Errors
One envelope, nine codes, and the complete catalogue of details.reason values with what to do about each.
Every failure on /api/v1 comes back in the same envelope, with a code drawn from a closed set of nine. That uniformity is the point: one error handler covers the entire API, and you never have to guess whether a given endpoint reports failure differently.
The interesting part is details.reason. The code tells you the class of problem — is this my fault, your fault, or a precondition? — and the reason tells you exactly which problem it is. Handle both, in that order.
The envelope #
{"error": {"code": "failed_precondition","message": "This workspace has no WhatsApp sender connected.","details": { "reason": "channel_not_connected" },"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
Five fields, always present.
error.codestringrequiredThe class of failure. Exactly nine values are possible — see the map below. Branch on this first.
invalid_argumentunauthenticatedpermission_deniednot_foundconflictfailed_preconditionrate_limitedprovider_errorinternalerror.messagestringrequiredOne human-readable sentence, written for a developer reading a log. Never branch on this. Wording can change without notice; codes and reasons cannot.
error.detailsobjectrequiredAlways an object — {} when there is nothing to add. Usually carries reason, and sometimes extra keys specific to that reason, such as required_scope, limit, window_seconds, retry_after_seconds, allowed_country_codes, deployed_agent_id, current_version, docs, or errors. The catalogue below lists them per reason.
error.request_idstringrequiredIdentifies this one request in our systems, prefix req_. Log it. Quoting it to support turns a support thread into a lookup.
error.correlation_idstringrequiredIdentifies the trace, prefix cor_. If you sent X-Correlation-Id, this echoes your value, which is how your logs and ours line up.
details.reason is optional, error.details is not
error.details is always an object, but it is sometimes empty. A handful of errors carry no reason — see errors without a reason. Write error.details?.reason (or .get("reason")), never error.details.reason on the assumption it exists.
The console's request log (Developers → Logs) shows the time, method and path, key, status with the error code, and duration of requests made with a valid key. It does not show request_id or correlation_id, so keep them in your own logs. Requests refused before the key is accepted (a 401, a workspace_suspended 403, or a rate_limited 429) are not recorded there at all.
Code to HTTP status #
Nine codes. No tenth code can exist: the error type rejects any value outside this map at construction time.
code |
HTTP | What it means | Whose move |
|---|---|---|---|
invalid_argument |
400 | The request is malformed or semantically wrong. | Fix the request. |
unauthenticated |
401 | The key is missing, malformed, unknown, revoked, expired, or from the wrong environment. | Fix the credential or the host. |
permission_denied |
403 | The key is valid but lacks the scope, the workspace is suspended, or the request reaches for something it may not touch (a blocked destination, an opted-out recipient, a number that isn't yours). | Read reason: replace the key, restore billing, or stop. |
not_found |
404 | No such resource in this workspace. | Check the id. |
conflict |
409 | The request collides with existing state. | Resolve the collision. |
failed_precondition |
412 | The request is well-formed but the workspace is not in a state where it can succeed: a channel not connected, an allowance used up, a guardrail refusing a campaign. | Change configuration, billing, or the campaign. |
rate_limited |
429 | Too many requests, too many concurrent calls, or the daily outbound call limit. | Back off, or wait until tomorrow. |
internal |
500 | Something broke on our side. | Retry, then escalate. |
provider_error |
502 | An upstream provider — the messaging or telephony network — failed or answered badly. | Retry with backoff. |
Note the two that surprise people:
- 412, not 501 or 503, for "this can't happen here yet". A 412 says the precondition for your request is unmet: no WhatsApp sender connected, no phone number on the workspace, the month's allowance and credits used up. The request would be perfectly valid once that precondition is met, and repeating it unchanged will keep failing until a human changes something in the console.
- 403 for a foreign resource, occasionally. Most cross-workspace access is answered with
404: a call, conversation, or ticket belonging to another workspace reads as "not found" rather than "not yours", which leaks nothing about whether the id exists. The exception isfrom_numberon a call, where a403is the honest answer to "that number is not yours".
Validation failures are 400, not 422 #
If you have used other Python-backed APIs you may expect a 422 Unprocessable Entity for schema violations. This API returns 400 with code: "invalid_argument", so that field-level validation and hand-written rules land in the same branch of your handler.
{"error": {"code": "invalid_argument","message": "Request validation failed.","details": {"errors": [{"loc": ["body", "to"],"msg": "String should match pattern '^\\+?\\d+$'","type": "string_pattern_mismatch"},{"loc": ["body", "template", "name"],"msg": "String should have at least 1 character","type": "string_too_short"}]},"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
The shape:
messageis always the literal"Request validation failed."— the useful detail is indetails.errors.details.errorsis an array, so several bad fields are reported at once rather than one per round trip.- Each entry has
loc(the path to the offending field, starting with"body"or"query"),msg(a human sentence), andtype(a stable machine token likestring_too_short,missing,greater_than_equal).
Note that a validation 400 has no details.reason: it has details.errors instead. That is the one place the two shapes differ, and it is why reason must always be read defensively.
The most common cause by a distance is phone-number formatting. to must match ^\+?\d+$: digits with an optional leading +, 6 to 20 characters. +919876543210 and 919876543210 both pass. +91 98765 43210, +91-98765-43210, and (+91) 98765 43210 are all rejected here, before anything is sent. Strip separators before you call.
Statuses the contract does not name #
A few failures happen before your request reaches any handler — a wrong method, an oversized body, the wrong content type. These still come back in the envelope, mapped by a simple rule:
- Any 4xx the contract does not name →
code: "invalid_argument", original status preserved. - Any 5xx the contract does not name →
code: "internal", original status preserved.
So a POST to a GET-only route returns HTTP 405 with code: "invalid_argument", and the framework's Allow response header is preserved alongside the envelope. Your handler should therefore branch on error.code for behaviour but log the HTTP status too — they do not always match the table above, and when they diverge the status is the more specific fact.
Unhandled exceptions on our side always render as:
{"error": {"code": "internal","message": "Internal server error.","details": {},"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
The message is deliberately opaque — the real exception is logged on our side only, because stack traces in an API response are a disclosure risk. This is the case where request_id earns its keep: send it to support and the underlying error is one lookup away.
The complete details.reason catalogue #
Grouped by where they come from. Every reason below is a stable string you can branch on.
Authentication — unauthenticated / 401 #
reason |
Meaning | Fix |
|---|---|---|
missing_api_key |
Neither Authorization: Bearer nor X-Api-Key reached us. |
Send the header. Check for a proxy stripping Authorization. |
malformed_api_key |
The token is not amk_{live|test}_{secret}. No lookup was performed. |
Truncated paste, wrapped line, quotes included, unsubstituted placeholder. |
invalid_api_key |
Well-formed, but no such key. | A key from another workspace, or one altered character. |
revoked_api_key |
The key was revoked in the console. Permanent. | Create a new key and redeploy. |
expired_api_key |
The old half of a roll, past its 24-hour grace. | Deploy the new secret. |
wrong_environment |
A test key against production, or a live key against a non-production host. |
Point the right key at the right host. The credential is fine. |
Full detail in Authentication.
Scope and standing: permission_denied / 403 #
reason |
Extra fields | Meaning |
|---|---|---|
missing_scope |
required_scope |
The key lacks the named scope. Scopes can't be added to an existing key: create a replacement key that holds it. |
workspace_suspended |
– | The workspace is suspended for billing. Every keyed route refuses, /ping included, until billing is restored in the console. The keys themselves are fine and start working again on their own. The suspension also stops everything else that serves customers (web chat, WhatsApp, calls, campaigns); see When a payment fails. |
See Scopes and Authentication.
Rate limiting: rate_limited / 429 #
reason |
Extra fields | Meaning |
|---|---|---|
rate_limited |
limit, window_seconds |
The per-key request limit. limit: 120, window_seconds: 60. |
call_concurrency |
limit |
Too many calls in progress for this workspace. limit: 10. Unrelated to the request limit. |
daily_call_budget |
calls, reserved_minutes, call_limit, minute_limit |
The workspace reached its daily outbound safety limit, counted in calls placed today and in minutes reserved for them. |
All three are 429, and they need different responses: one waits for the next minute, one waits for a call to end, and one waits for tomorrow. See Rate limits.
Idempotency and versions: conflict / 409 #
reason |
Extra fields | Meaning |
|---|---|---|
idempotency_key_reuse |
– | The same Idempotency-Key was already used on this endpoint, with a different request body. |
idempotency_in_progress |
retry_after_seconds |
A request with this Idempotency-Key is still pending. retry_after_seconds: 2. Retry after that and you get the stored response once the first request succeeds. A request that returns an error releases its key, so a retry after that runs fresh; see Idempotency. |
version_conflict |
expected_version, current_version, or eligible |
A campaign or Duesly payment changed since you read it. Read it again and retry with the new version. On a campaign launch with confirm_recipients, it means the audience is now a different size; eligible is the new count. |
A stale expected_version on PATCH /tickets/{ticket_id} is also a 409 conflict, but it carries no reason: only expected_version and current_version, with the message "Ticket changed since it was loaded."
See Idempotency.
Contacts — conflict / 409 #
reason |
Meaning |
|---|---|
identity_conflict |
The email and phone you sent already belong to two different existing contacts. |
The API refuses to guess which one you meant, and refuses to merge them silently. Message: "Those details belong to two different contacts." Resolve the duplicate in the console, or send one identifier rather than two.
Messages #
reason |
Code / HTTP | Meaning |
|---|---|---|
empty_message |
invalid_argument / 400 |
Neither text nor template was provided. Message: "Provide either \text` or `template`."` |
template_variables |
invalid_argument / 400 |
The wrong number or shape of variables for that template's placeholders. |
template_not_found |
not_found / 404 |
No approved template by that name in this workspace. Message: "No approved WhatsApp template named '<name>'." |
suppressed |
permission_denied / 403 |
The recipient has opted out of WhatsApp messages from this workspace. |
session_window_closed |
conflict / 409 |
Free-form text outside the 24-hour customer service window. Send an approved template instead. |
channel_not_connected |
failed_precondition / 412 |
The workspace has no WhatsApp sender connected, or sender_id names one that isn't. |
allowance_exhausted |
failed_precondition / 412 |
The workspace has used every conversation its plan includes this month and has no prepaid credits left to cover more. Nothing was sent. Add credits or upgrade the plan. |
channel_dormant |
failed_precondition / 412 |
The workspace's plan no longer includes WhatsApp, so the sender has stopped sending. Upgrade the plan to restore it. |
workspace_suspended |
failed_precondition / 412 |
The workspace is suspended for an unpaid plan invoice, so every WhatsApp send is refused. A suspended workspace's key is normally refused earlier, with the 403 above; this 412 is the same state caught at the moment of sending. |
template_not_found catches two different mistakes: a genuine typo, and a template that exists but is not yet approved. Both look identical from here. Check the approval state in the console before you assume it is a spelling problem. Templates covers the lifecycle.
When WhatsApp itself refuses a send, the response is 502 provider_error with no reason. details carries WhatsApp's own provider_code, provider_subcode, provider_message, and provider_user_message, and message starts with "WhatsApp rejected this:".
Calls #
reason |
Code / HTTP | Meaning |
|---|---|---|
voice_not_configured |
failed_precondition / 412 |
Voice is not configured for this deployment. |
workspace_suspended |
failed_precondition / 412 |
Calling is paused on this workspace. A suspended workspace's key is normally refused earlier, with the 403 above; this 412 is the same state caught at the moment of dialling. |
wallet_empty |
failed_precondition / 412 |
The workspace has used every voice minute its plan includes this month and has no prepaid credits left. Inbound calls still answer and calls in progress still finish. Not retryable: someone has to add credits or upgrade. |
channel_dormant |
failed_precondition / 412 |
The workspace's plan no longer includes phone numbers, so the line has stopped calling. The number is held for a short time before it is released; upgrade the plan to keep it. |
country_not_allowed |
permission_denied / 403 |
The destination country is outside the outbound allowlist. details.allowed_country_codes lists the calling codes you can dial, such as ["91"]. |
destination_blocked |
permission_denied / 403 |
The destination is in a premium-rate, satellite, or otherwise blocked range. |
daily_call_budget |
rate_limited / 429 |
The workspace reached its daily call-count or reserved-minute safety limit. details carries calls, reserved_minutes, call_limit, and minute_limit. |
call_concurrency |
rate_limited / 429 |
Ten calls are already in progress on this workspace. details.limit is 10. |
self_dial |
invalid_argument / 400 |
The destination is one of this workspace's own numbers. |
no_phone_number |
failed_precondition / 412 |
The workspace owns no phone number to call from. See Numbers and KYC. |
unknown_from_number |
permission_denied / 403 |
The from_number you specified is not owned by this workspace. |
no_agent_deployed |
failed_precondition / 412 |
No agent is deployed to the caller-ID number, so nobody would be on the line. |
agent_number_mismatch |
failed_precondition / 412 |
The agent_id you asserted is not the agent deployed to that number. details.deployed_agent_id names the one that is. |
originate_failed |
internal / 500 |
The call could not be placed on the telephony network. |
agent_number_mismatch is worth understanding rather than working around. The agent that speaks on an outbound call is resolved from the caller-ID number, not from agent_id — agent_id is an assertion, and this error is that assertion failing. details.deployed_agent_id tells you what is actually deployed there. See Outbound calls.
Campaigns #
A campaign started over the API passes the same guardrails as one started in the console. When one refuses, the response is 412 failed_precondition with details.reason, a link to the page that explains it in details.docs, and, for guardrail findings, details.fix and the full details.findings list. The reasons you will meet most:
reason |
Code / HTTP | Meaning |
|---|---|---|
template_not_found |
invalid_argument / 400 |
POST /campaigns on WhatsApp without a template. Note the 400, unlike POST /messages. |
invalid_argument |
invalid_argument / 400 |
A call campaign without agent_id, or script, capture, or call_back_incomplete on a WhatsApp campaign. |
campaign_frozen |
failed_precondition / 412 |
The campaign is not a draft or paused, so it can't be launched. |
channel_dormant |
failed_precondition / 412 |
The plan no longer includes the campaign's channel. |
allowance_exhausted |
failed_precondition / 412 |
The plan's included conversations or voice minutes are used up and there are no credits to cover more. |
workspace_suspended |
failed_precondition / 412 |
The workspace is suspended for an unpaid plan invoice, so a campaign can't be launched or resumed. Over the API the key's 403 normally fires first. A campaign that was already running is held, not failed, and carries on by itself once the invoice is paid. |
plan_limit |
failed_precondition / 412 |
The audience is larger than the number of recipients the plan allows per campaign. |
audience_too_large |
failed_precondition / 412 |
The audience is over the per-campaign recipient ceiling. |
audience_empty |
failed_precondition / 412 |
Nobody in the audience can be sent to once exclusions are applied. |
template_not_approved |
failed_precondition / 412 |
The campaign's template isn't approved yet. |
not_a_call_campaign |
failed_precondition / 412 |
GET /campaigns/{id}/outcomes or outcomes.csv on a WhatsApp campaign. |
version_conflict |
conflict / 409 |
See Idempotency and versions. |
Every guardrail reason, what it means, and how to fix it is listed in Sending rules. Branch on reason and log docs: it is the same page the console links from a blocked launch.
Duesly #
reason |
Code / HTTP | Meaning |
|---|---|---|
not_found |
failed_precondition / 412 |
The Duesly app is not installed in this workspace. details.docs links to Apps. Note the 412: a 404 would look like a typo in the URL. |
version_conflict |
conflict / 409 |
The due changed since you read it. details.current_version is the new version. |
conflict |
failed_precondition / 412 |
The due is no longer open, for example it is already paid. details.current_status says what it is. |
See Duesly (payments).
Errors that carry no reason #
These come back with details: {} and rely on code plus message:
| Situation | Code / HTTP | Message |
|---|---|---|
| Unknown or foreign call id | not_found / 404 |
Call not found. |
| Unknown or foreign conversation id | not_found / 404 |
Conversation not found. |
| Unknown or foreign ticket id | not_found / 404 |
Ticket not found. |
| Unknown or foreign contact id, or no match on lookup | not_found / 404 |
Contact not found. |
GET /contacts/lookup with neither email nor phone |
invalid_argument / 400 |
Provide an email or a phone number. |
| Ticket status change the workflow doesn't allow | invalid_argument / 400 |
Cannot move a ticket from <status> to <status>. |
| Call destination that isn't a dialable number | invalid_argument / 400 |
That destination number isn't usable. |
POST /contacts with neither email nor phone |
invalid_argument / 400 |
Provide an email or a phone number. |
POST /tickets with no requester detail |
invalid_argument / 400 |
Provide at least one requester detail (name, email, or phone). |
| Call destination shorter than 8 digits | invalid_argument / 400 |
That destination number is too short. |
| Schema validation failure | invalid_argument / 400 |
Request validation failed. — carries details.errors instead |
| Unhandled server exception | internal / 500 |
Internal server error. |
The not_found cases are worth a note: an id belonging to another workspace returns exactly the same 404 as an id that does not exist. That is an intentional guard — the API will not confirm the existence of a resource you cannot see. If you are certain an id is real and still getting 404, check which workspace's key you are holding.
How to handle errors well #
1. Branch on code, then on reason #
code decides the shape of your response — retry, alert, give up, surface to a user. reason decides the message you write into your own log or your own UI. Never parse message, and never branch on the HTTP status alone: originate_failed is a 500 that means "the call did not go through", which is a different thing from a generic server error even though the status matches.
2. Log request_id on every failure #
Log it structurally, next to your own trace id, on every non-2xx. It is the only identifier that lets us find your exact request. Send X-Correlation-Id on the way in and error.correlation_id will echo it, so one grep spans both systems.
3. Know what is retryable #
| Class | Retry? | How |
|---|---|---|
rate_limited / rate_limited |
Yes | Wait for the next minute boundary. See Rate limits. |
rate_limited / call_concurrency |
Yes | Wait for a call to finish: seconds to minutes, not milliseconds. |
rate_limited / daily_call_budget |
Not today | The limit is per day. Hold the work until tomorrow. |
conflict / idempotency_in_progress |
Yes, briefly | Wait retry_after_seconds (2), then send the same request with the same key. You get the first request's stored response, or a fresh attempt if it failed. |
conflict / version_conflict |
Yes, after a read | Read the resource again and retry with its current version. |
internal (500) |
Yes | Exponential backoff, a small number of attempts, then alert. A keyed create can retry with the same key: an error response releases it. For POST /messages and POST /calls, first confirm nothing was sent, because a 5xx can come after the send. |
provider_error (502) |
Yes | The upstream network hiccuped. Backoff, then alert. As with 500, the same key is free again; confirm nothing was sent first. |
| Connection errors, timeouts | Yes | Treat as internal. Assume the request may have succeeded. |
invalid_argument (400) |
No | The request is wrong. Retrying it identically is wrong identically. |
unauthenticated (401) |
No | Except after you have deployed a new secret. |
permission_denied (403) |
No | Nothing changes by waiting. Alert a human: replace the key, or restore billing for workspace_suspended. |
not_found (404) |
No | Unless you are polling for something you believe is about to exist. |
Other conflict (409) |
No | idempotency_key_reuse, identity_conflict, and session_window_closed need your input, not time. |
failed_precondition (412) |
No | A human must change configuration. Retrying floods your logs and fixes nothing. |
Always retry POSTs with an Idempotency-Key
A timeout tells you nothing about whether the request ran. A message may have been delivered, a call may be ringing. Send Idempotency-Key on POST /messages, /calls, /tickets, /campaigns, and /apps/duesly/subscribers, and a retry replays the stored response instead of doing the thing twice. Without it, your retry is a second WhatsApp message to a real customer. See Idempotency.
4. Fail loudly on 412 #
failed_precondition almost always means a configuration or billing gap: no sender connected, no phone number, no agent deployed to the number, the month's allowance used up. These are not transient and they are not your code's fault. Route them to an alert with the reason attached rather than into a retry queue where they will churn silently until someone notices no messages went out today.
5. A handler you can copy #
const RETRYABLE_CODES = new Set(["rate_limited", "internal", "provider_error"]);class AmomicError extends Error {constructor(status, error) {super(`${error.code}: ${error.message}`);this.status = status;this.code = error.code;this.reason = error.details?.reason ?? null;this.details = error.details ?? {};this.requestId = error.request_id;this.correlationId = error.correlation_id;this.retryable = RETRYABLE_CODES.has(error.code);}}async function amomicRequest(path, options = {}, attempt = 1) {const response = await fetch(`https://services.amomic.in/api/v1${path}`, {...options,headers: {Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,"Content-Type": "application/json",...options.headers,},});if (response.ok) return response.json();const body = await response.json();const err = new AmomicError(response.status, body.error);console.error("amomic_api_error", {status: err.status,code: err.code,reason: err.reason,request_id: err.requestId,correlation_id: err.correlationId,path,});// Configuration problems need a human, not a retry.if (err.code === "failed_precondition" || err.code === "permission_denied") {await alertOps(err);throw err;}// The daily call limit resets tomorrow, not in a few seconds.if (err.reason === "daily_call_budget") throw err;if ((err.retryable || err.reason === "idempotency_in_progress") && attempt < 4) {// The per-key limit resets on the minute boundary; everything else backs off.const waitMs =err.reason === "rate_limited"? (60 - new Date().getSeconds()) * 1000 + 250: err.reason === "idempotency_in_progress"? (err.details.retry_after_seconds ?? 2) * 1000: 500 * 2 ** (attempt - 1);await new Promise((r) => setTimeout(r, waitMs));return amomicRequest(path, options, attempt + 1);}throw err;}
Both examples send Idempotency-Key only if you pass one — on any POST, you should. Add X-Correlation-Id from your own tracing context and the two systems' logs join on one value.
Next #
Rate limits
120 per key per minute, a fixed window, and how to back off without a Retry-After.
Idempotency
Make retries safe on the five creates that honour the header.
Scopes
A missing scope names itself: here is the full matrix.
Troubleshooting
Symptom-first debugging for when the error is not the problem.