# Errors

> One envelope, nine codes, and the complete catalogue of details.reason values with what to do about each.

Source: https://docs.amomic.in/api/errors

---

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

```json title="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.

```params
[
  {"name": "error.code", "type": "string", "required": true, "enum": ["invalid_argument", "unauthenticated", "permission_denied", "not_found", "conflict", "failed_precondition", "rate_limited", "provider_error", "internal"], "description": "The class of failure. Exactly nine values are possible — see the map below. Branch on this first."},
  {"name": "error.message", "type": "string", "required": true, "description": "One human-readable sentence, written for a developer reading a log. <strong>Never branch on this.</strong> Wording can change without notice; codes and reasons cannot."},
  {"name": "error.details", "type": "object", "required": true, "description": "Always an object — <code>{}</code> when there is nothing to add. Usually carries <code>reason</code>, and sometimes extra keys specific to that reason, such as <code>required_scope</code>, <code>limit</code>, <code>window_seconds</code>, <code>retry_after_seconds</code>, <code>allowed_country_codes</code>, <code>deployed_agent_id</code>, <code>current_version</code>, <code>docs</code>, or <code>errors</code>. The catalogue below lists them per reason."},
  {"name": "error.request_id", "type": "string", "required": true, "description": "Identifies this one request in our systems, prefix <code>req_</code>. Log it. Quoting it to support turns a support thread into a lookup."},
  {"name": "error.correlation_id", "type": "string", "required": true, "description": "Identifies the trace, prefix <code>cor_</code>. If you sent <code>X-Correlation-Id</code>, this echoes your value, which is how your logs and ours line up."}
]
```

::: warning 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](#errors-that-carry-no-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.

```json title="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:

```json title="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](/api/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](/api/scopes#changing-what-a-key-can-do) 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](/operate/plan-changes#when-a-payment-fails). |

See [Scopes](/api/scopes) and [Authentication](/api/authentication#the-validation-order).

### 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](/api/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](/api/idempotency#after-an-error-response-retry-with-the-same-key). |
| `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](/api/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](/operate/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](/channels/whatsapp-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](/operate/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](/channels/phone-numbers). |
| `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](/channels/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](#idempotency-and-versions-conflict-409). |

Every guardrail reason, what it means, and how to fix it is listed in [Sending rules](/campaigns/sending-rules#every-refusal-and-what-it-means). 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](/apps/overview). 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)](/api/apps-duesly).

### 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](/api/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. |

::: danger 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](/api/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

::: codegroup

```javascript 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;
}
```

```python Python
import logging, os, time
import requests

RETRYABLE_CODES = {"rate_limited", "internal", "provider_error"}
BASE = "https://services.amomic.in/api/v1"
log = logging.getLogger(__name__)


class AmomicError(Exception):
    def __init__(self, status: int, error: dict):
        super().__init__(f"{error['code']}: {error['message']}")
        self.status = status
        self.code = error["code"]
        self.details = error.get("details") or {}
        self.reason = self.details.get("reason")
        self.request_id = error.get("request_id")
        self.correlation_id = error.get("correlation_id")
        self.retryable = self.code in RETRYABLE_CODES


def amomic_request(method: str, path: str, **kwargs):
    headers = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}
    headers.update(kwargs.pop("headers", {}))

    for attempt in range(1, 5):
        response = requests.request(
            method, f"{BASE}{path}", headers=headers, timeout=30, **kwargs
        )
        if response.ok:
            return response.json()

        err = AmomicError(response.status_code, response.json()["error"])
        log.error(
            "amomic_api_error",
            extra={
                "status": err.status,
                "code": err.code,
                "reason": err.reason,
                "request_id": err.request_id,
                "correlation_id": err.correlation_id,
                "path": path,
            },
        )

        # Configuration problems need a human, not a retry.
        if err.code in ("failed_precondition", "permission_denied"):
            alert_ops(err)
            raise err

        # The daily call limit resets tomorrow, not in a few seconds.
        in_progress = err.reason == "idempotency_in_progress"
        if err.reason == "daily_call_budget" or attempt == 4:
            raise err
        if not (err.retryable or in_progress):
            raise err

        if err.reason == "rate_limited":
            # The per-key window is fixed on the minute; wait it out.
            wait = 60 - time.gmtime().tm_sec + 0.25
        elif in_progress:
            wait = err.details.get("retry_after_seconds", 2)
        else:
            wait = 0.5 * (2 ** (attempt - 1))
        time.sleep(wait)
```

:::

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

::: cards

::: card Rate limits
[120 per key per minute](/api/rate-limits), a fixed window, and how to back off without a `Retry-After`.
:::

::: card Idempotency
[Make retries safe](/api/idempotency) on the five creates that honour the header.
:::

::: card Scopes
[A missing scope names itself](/api/scopes): here is the full matrix.
:::

::: card Troubleshooting
[Symptom-first debugging](/operate/troubleshooting) for when the error is not the problem.
:::

:::
