Skip to content

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 #

412 Precondition Failed
{
"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.codestringrequired

The class of failure. Exactly nine values are possible — see the map below. Branch on this first.

invalid_argumentunauthenticatedpermission_deniednot_foundconflictfailed_preconditionrate_limitedprovider_errorinternal
error.messagestringrequired

One human-readable sentence, written for a developer reading a log. Never branch on this. Wording can change without notice; codes and reasons cannot.

error.detailsobjectrequired

Always 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_idstringrequired

Identifies this one request in our systems, prefix req_. Log it. Quoting it to support turns a support thread into a lookup.

error.correlation_idstringrequired

Identifies 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 is from_number on a call, where a 403 is 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.

400 Bad Request
{
"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:

  • message is always the literal "Request validation failed." — the useful detail is in details.errors.
  • details.errors is 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), and type (a stable machine token like string_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:

500 Internal Server Error
{
"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 #

Node.js
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.