# Troubleshooting

> Symptom-first index of every error the API returns — what you see, what it means, and how to fix it, including all six 401 reasons, plan and credit refusals, the three kinds of 429, webhook signature failures, and calls stuck in ringing.

Source: https://docs.amomic.in/operate/troubleshooting

---

Something is not working. This page is organised by what you are looking at, not by what we think the cause is. Find your symptom, read what it means, apply the fix.

## Before you dig

Three things to have in front of you. They resolve a surprising share of cases on their own.

**The error envelope.** Every non-2xx response carries the same shape, and it names the problem twice:

```json
{
  "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"
  }
}
```

`error.code` is the class of problem — nine possible values. `error.details.reason` is the specific cause. Read both before forming a theory.

**`GET /ping`.** It costs nothing, changes nothing, and answers most authentication questions outright:

```bash
curl https://services.amomic.in/api/v1/ping \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```json title="200 OK"
{
  "ok": true,
  "workspace_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4",
  "key_name": "Order service",
  "environment": "live",
  "scopes": ["agents:read", "conversations:read", "messages:send"]
}
```

Which key is this really? Which workspace? Which environment? Which scopes? All four, in one call, with no side effects.

**The request log.** **Developers → Logs** in the console shows the newest 50 requests made with a valid key: time, method and path, key name, status with its error code, and duration. When your logs and your theory disagree, it is the tiebreaker. It doesn't show `request_id` and has no filter, so match by time and key name. Requests refused before the key is recognised (a missing, malformed, wrong or revoked key, a suspended workspace, or the rate limit) don't appear there at all. See [Logs](/console/developers#logs).

## Symptom index

| What you are seeing | Go to |
|---|---|
| `401` on every call, or suddenly after a deploy | **401 — the key was not accepted** |
| The key works from my laptop but not from production | **401 — `wrong_environment`** |
| `403` on one endpoint only | **403 — `missing_scope`** |
| WhatsApp text fails but templates work | **409 — `session_window_closed`** |
| A retry returns `409` instead of the original response | **409 — `idempotency_key_reuse`** |
| Creating a contact returns `409` | **409 — `identity_conflict`** |
| Every message fails identically | **412 — `channel_not_connected`** |
| An endpoint says the channel or resource is unavailable | **412 — `unsupported_channel` / `unsupported_resource`** |
| Calls fail before they ring | **412 — voice preconditions** |
| A template that exists returns `404` | **404 — `template_not_found`** |
| A template send returns `400` | **400 — `template_variables`** |
| A message send returns `400` with no obvious cause | **400 — `empty_message`** |
| `429` under load, or on calls | **429 — three different limits** |
| My webhook handler rejects everything as unsigned | **Webhook signature mismatch** |
| My webhook handler stopped being called | **Webhook endpoint auto-disabled** |
| Every message or call fails with `412` after a plan or credit change | **412 — plan, credits and wind-down** |
| A retry returns `409` with `idempotency_in_progress` | **409 — `idempotency_in_progress`** |
| A call never leaves `ringing` in my database | **A call stuck in `ringing`** |
| The agent keeps saying it does not know | **The agent answers "I don't know" too often** |
| A phone number is rejected before anything happens | **A phone number rejected at validation** |

---

## 401 — the key was not accepted

Six different problems produce a `401`, and `details.reason` tells you which. They need four different responses, so read the reason before paging anyone. Every `401` also carries `WWW-Authenticate: Bearer`.

The checks run in order, which is itself diagnostic: a malformed key never reaches a database lookup, so `malformed_api_key` and `invalid_api_key` mean genuinely different things.

### `missing_api_key`

**What you see.** `"Provide your API key as \`Authorization: Bearer amk_…\` or \`X-Api-Key\`."`

**What it means.** No key arrived. Not a wrong key — no key at all.

**How to fix.** In order of how often each is the culprit: the environment variable is empty in this environment (a secret that exists in staging and not in production, or a typo in the variable name); a proxy or gateway is stripping the `Authorization` header; the header was built as `Bearer` with no value because the variable resolved to an empty string; or the header name is misspelled. Print the *length* of your secret at startup — never the value — and you will catch the empty-variable case immediately.

Two header forms are accepted. `X-Api-Key: amk_live_…` is checked **first** and wins if both are present, which matters if a shared HTTP client is injecting one of them.

### `malformed_api_key`

**What you see.** `"That doesn't look like an Amomic-AI API key."`

**What it means.** The token does not parse. A key is exactly `amk`, an underscore, `live` or `test`, an underscore, and a secret of at least 20 characters. No lookup was attempted, so this says nothing about whether the key exists.

**How to fix.** Look at what you actually sent. Common causes: a trailing newline from `$(cat secret.txt)`; a quoted string that kept its quotes; a truncated paste; the *masked* display form `amk_live_7Fk2xQ…` copied out of the console list instead of the real secret; or a `Bearer ` prefix that got included in the variable and then prefixed again.

### `invalid_api_key`

**What you see.** `"That API key is not valid."`

**What it means.** The token parsed, and no key matching it exists. The shape was right; the secret was not.

**How to fix.** Almost always the wrong secret rather than a broken one: an old value in a secret manager, a mistyped or partial copy, or a key that never existed in this environment. Create or roll a key, deploy the new secret, and confirm with `GET /ping`. If `ping` works from your machine with the same string, the difference is in how your service is loading it, not in the key.

### `revoked_api_key`

**What you see.** `"That API key has been revoked."`

**What it means.** Someone revoked this key. It is permanent — there is no un-revoke.

**How to fix.** Revoked keys stay in **Developers → API keys** marked **Revoked**; match the masked prefix to confirm it is this one. Find out who revoked it and why before creating a replacement; a revocation is usually a deliberate response to a suspected leak, and reissuing the same access without knowing that is how the same incident happens twice. Then create a new key, scoped to what the system needs, and deploy it.

Revocation takes effect **within seconds**, so this appearing mid-run is normal and not a caching bug.

### `expired_api_key`

**What you see.** `"That API key was rolled and its grace period has ended."`

**What it means.** This key was rolled more than **24 hours** ago. The roll issued a replacement and kept this one alive for a day so you could deploy without downtime. The day passed.

**How to fix.** Deploy the replacement key, which was shown once at the moment of the roll. If nobody saved it, roll the replacement (the **Active** row with the same name) and deploy the new one promptly this time.

Prevention: treat a roll as a deploy with a deadline, not as a task you have finished when the dialog closes. And rehearse one in a non-production environment first, so you find out whether your service reloads secrets without a restart during a rehearsal rather than during the grace window.

### `wrong_environment`

**What you see.** `"That is a test key; this environment expects a live key."` — or the reverse.

**What it means.** **The key may be fine; it is being sent to the wrong host.** Keys carry an environment, and each host accepts only its own. Every key you create in the Amomic-AI console is a live key (`amk_live_…`) for `https://services.amomic.in`, so a `test` key there came from somewhere other than your console.

**How to fix.** Check which key your service actually loaded and which base URL it calls. Load both from the same configuration source, and assert on startup that they match:

```python
import os

BASE_URL = os.environ["AMOMIC_BASE_URL"]
API_KEY = os.environ["AMOMIC_API_KEY"]

if BASE_URL.startswith("https://services.amomic.in") and not API_KEY.startswith("amk_live_"):
    raise SystemExit("A live key is required for services.amomic.in")
```

This is almost always a deployment bug wearing an authentication costume. The on-call engineer sees `401`, rolls the key, deploys the new one, and gets the same `401`, because the key was never the problem.

::: note There is no sandbox
There is no test mode, test header or sandbox on the production host. Test against your own phone numbers, with a key scoped to what you're testing.
:::

---

## 403 — the key is valid but not allowed

### `missing_scope`

**What you see.**

```json title="403 Forbidden"
{
  "error": {
    "code": "permission_denied",
    "message": "This API key does not have the `messages:send` scope.",
    "details": { "reason": "missing_scope", "required_scope": "messages:send" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

**What it means.** The key authenticated. It is not permitted to do this. `details.required_scope` names exactly what it needs — no guessing.

**How to fix.** A key's scopes can't be changed after it is created. Create a new key in **Developers → API keys** with the scopes the integration needs, deploy it, and revoke the old one once the request log shows the new key in use. Rolling doesn't help: the replacement copies the old scopes.

The two things that produce this unexpectedly:

- **Create key starts read-only.** Only **Read conversations** and **Read agents** are ticked. A key created with those defaults and then used to send messages hits this on its first real request.
- **Reading a ticket requires `tickets:write`.** There is no `tickets:read` scope. A key that only looks tickets up must also be able to create them — an asymmetry worth knowing when you are sizing keys.

`GET /ping` returns the key's exact scope list, which is faster than reasoning about what you think you granted. There are eleven scopes; see [Scopes](/api/scopes).

### `workspace_suspended`

**What you see.** `403` with `"This workspace is suspended. Restore billing to use its API keys."`

**What it means.** Every key in the workspace is refused until billing is restored. These requests don't appear in the request log.

**How to fix.** The owner resolves it on [Billing](/console/billing).

---

## 400 — the request was wrong

Validation failures return `400` with `code: "invalid_argument"`, the message `Request validation failed.`, and `details.errors` naming the offending field. These are code defects, not transient conditions: **do not retry a `400`.**

### `empty_message`

**What you see.** `"Provide either \`text\` or \`template\`."` with `details.reason` of `empty_message`.

**What it means.** `POST /messages` received neither a `text` nor a `template`. There is nothing to send.

**How to fix.** Usually one of three: your template lookup returned nothing and you posted the result anyway; `text` was built from a variable that resolved to `""` or `null`; or a conditional built the body with `text` on one branch and `template` on the other and a third branch built neither. Guard before you post — a message with no content is a bug you can catch a function earlier, without a round trip.

### `template_variables`

**What you see.** `400` with `details.reason` of `template_variables`.

**What it means.** The variables you supplied do not fit the approved template's components. Wrong count, or the right count in the wrong place.

**How to fix.** Compare your request against the approved template, component by component. See [Where templates live](/channels/whatsapp-templates#where-templates-live). The usual mistake is treating all variables as one list:

```json
{
  "to": "+919876543210",
  "template": {
    "name": "order_shipped",
    "language": "en_US",
    "header_variables": ["Asha"],
    "variables": ["AM-4821", "2-3 days"],
    "button_variables": ["4821"]
  }
}
```

Header, body, and button variables are **three separate fields**. A template with a variable in its header needs `header_variables`, not an extra entry at the front of `variables`.

`variables` accepts either a positional array or a numbered object — `["Asha", "AM-4821"]` and `{"1": "Asha", "2": "AM-4821"}` are the same request. It does not accept named placeholders.

### A field is too long, or the wrong shape

`details.errors` names the field and the rule. The bounds that catch people: `text` at 4096 characters, ticket `subject` at 240, ticket `description` at 8000, `purpose` at 500, `tags` at 10 items. Truncate on your side before sending — a transcript excerpt or a generated description is exactly the kind of field that fits in testing and overflows in production. Full table in [Limits and plans](/operate/limits).

### `"Provide at least one requester detail (name, email, or phone)."`

`POST /tickets` with all three requester fields null. Note the empty `details` — no `reason` string — so branch on the code and message. Synthesise something rather than failing: a name built from the conversation id is a valid requester and infinitely more useful than a failed escalation. See [Escalate a conversation to a ticket](/recipes/support-escalation).

### `"Provide an email or a phone number."`

`POST /contacts` with only a `name`. A contact with no reachable identity cannot be matched to anything, so a name-only request is refused.

---

## 404 — not found

### `template_not_found`

**What you see.** `404` with `"No approved WhatsApp template named 'order_shipped'."` and `details.reason` of `template_not_found`.

**What it means.** No **approved** template with that name exists in this workspace. Three different situations produce the identical error.

**How to fix.** Check them in this order:

1. **It is not approved yet.** Only `approved` templates can be sent. `pending` and `rejected` both produce this same `404` — the API does not distinguish them. Look at the status in the console.
2. **The name differs.** Template names are lowercase with underscores. `order_shipped` is not `Order_Shipped` and not `order-shipped`.
3. **It was approved in a different workspace.** Templates belong to the WhatsApp Business Account connected to *this* workspace. A template your colleague approved in a different workspace does not exist here.

Not retryable. Fail the job and alert — and alert on this **separately** from generic message failures, because it is a configuration incident with a specific fix, and a generic "message failed" alert buries the one line that says what to do.

### `Conversation not found.` / `Call not found.` / `Ticket not found.`

**What it means.** The id does not exist, **or** it belongs to another workspace. The two cases are deliberately indistinguishable — telling you an id exists but is not yours would confirm the existence of another workspace's data.

**How to fix.** Check the id you stored, and check which key you are using. If a call id worked yesterday and 404s today, the most likely explanation is that you switched keys and the new key belongs to a different workspace.

On the messages endpoint this is a feature: a bad `conversation_id` returns `404` rather than `200` with an empty list, so a typo fails on the first call instead of silently producing an empty report for three weeks.

---

## 409 — conflict

### `session_window_closed`

**What you see.**

```json title="409 Conflict"
{
  "error": {
    "code": "conflict",
    "message": "The 24-hour session window is closed; only approved template messages can be sent.",
    "details": { "reason": "session_window_closed" },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

**What it means.** You sent free-form `text` to a customer whose 24-hour service window is not open. The window opens when **the customer** messages you and closes 24 hours after their most recent message. WhatsApp enforces it; we report it.

**How to fix.** Send an approved template instead. Templates exist precisely to bypass this window.

The deeper fix is structural: decide per flow rather than per send. **Notification flows send a template every time** — an order shipped, a payment failed, an appointment reminder. **Reply flows send text**, inside a window the customer opened. Do not "try text and fall back to a template": you spend a round trip discovering a state you already know, and customers whose window happens to be open receive differently-worded messages from everyone else.

Note the clock is per customer and not per order. It runs from *their* last message, and each new message from them extends it. [The 24-hour window](/channels/whatsapp-window) covers the rule in full.

::: note A related 403: `suppressed`
A customer who replied `STOP` (or `unsubscribe`, `cancel`, `opt out`, and the Hindi equivalents) is suppressed for your workspace, and further sends return `403 permission_denied` with `"Recipient has opted out."` and `details.reason` of `suppressed`. This applies to **templates too** — an opt-out is not a window problem and cannot be worked around by switching message type. Record the suppression against your own customer record so you stop trying. `START` resubscribes them.
:::

### `idempotency_key_reuse`

**What you see.** `409` with `"This Idempotency-Key was already used with a different request body."`

**What it means.** The same `Idempotency-Key` arrived within 24 hours attached to a **different body**. The match is a hash of the raw request bytes.

**How to fix.** One of two causes, and they need opposite fixes.

**The body changes between attempts.** Something inside it is regenerated per attempt: a timestamp, a trace id, a "checked at" line in a description, or a serialiser producing a different key order. Fix: build the body **once**, keep the serialised string, and resend that exact string on every retry.

```python
body = json.dumps(payload)      # once
for attempt in range(4):
    response = client.post(url, headers=headers, content=body)   # the same bytes
```

**One key is being used for two different messages.** `order-4821` used for both the "shipped" and the "delivered" notification will collide on the second. Fix: one key per real-world **event**, not per order — `order-4821-shipped` and `order-4821-delivered`.

::: warning Do not retry with a fresh key to "get past" this
That is exactly the double-send the header exists to prevent. A `409` here is the system doing its job.
:::

### `idempotency_in_progress`

**What you see.** `409` with `"A request with this Idempotency-Key is already in progress. Do not send it again."` and `details.retry_after_seconds` of `2`.

**What it means.** An earlier request with the same key was accepted and hasn't finished. A request that ends in an error releases its key, so this means the first one is still running, or stopped before it could finish.

**How to fix.** Wait two seconds and retry once, with the same key and the same bytes. If you still get this, the first attempt didn't complete. Treat its outcome as unknown: check whether the message, call or ticket exists (in the [Inbox](/console/inbox), or with `GET /calls/{call_id}`) before you send again.

### `identity_conflict`

**What you see.** `409` with `"Those details belong to two different contacts."` and `details.reason` of `identity_conflict`.

**What it means.** `POST /contacts` was given an email that matches one existing contact and a phone that matches a **different** one. Rather than guessing which record is right — or silently merging two customers into one — the API refuses.

**How to fix.** Decide, then send one identity at a time. Send just the email to resolve to that contact, or just the phone to resolve to the other. Then reconcile the duplicate in the console, where a human can see both records.

This usually surfaces a real data problem on your side: the same person entered twice with a typo in one address, or a phone number reassigned to a new customer. Worth investigating rather than working around.

---

## 412 — the workspace is not in a state to do this

`412 failed_precondition` means the request was well-formed and the key was fine, but something in the workspace's configuration has to change first. **These are never transient — do not retry them.** A retry loop against a `412` will run until your queue gives up, and it will not have sent anything.

### `channel_not_connected`

**What you see.** `412` with `"This workspace has no WhatsApp sender connected."`

**What it means.** No WhatsApp sender is connected. Not a bad number, not a bad message — nothing to send from.

**How to fix.** Connect a sender in the console under **Channels → WhatsApp**. See [Set up WhatsApp](/channels/whatsapp). Until someone does, every message will fail identically.

Alert on this separately from message failures. It is a single configuration incident that will produce thousands of identical errors, and folding it into a generic alert makes it look like a message-quality problem.

If your workspace has several senders and you need a specific one, pass `sender_id` from `GET /channels`. Omit it and the workspace default is used.

### `unsupported_channel` and `unsupported_resource`

**What you see.** `412` with a message like `"The whatsapp channel is not available on this deployment."` or `"Conversations are not available on this deployment."`, with `details.reason` of `unsupported_channel` (plus a `supported` array) or `unsupported_resource`.

**What it means.** The capability itself is not present where you are pointing. This is different from `channel_not_connected`, which means the capability exists but nothing is configured on it.

**How to fix.** Call `GET /api/v1/channels` to see what this deployment can actually do — that is what the endpoint is for. On the standard production surface these should not fire; if one does, check that you are calling the host you think you are calling.

Note that these are `412`, not `501` or `503`. That is deliberate: the request cannot succeed until a precondition changes, and it is not a transient outage you should retry into.

### Voice preconditions

`POST /calls` has several, and each names its own cause:

| `details.reason` | What it means | How to fix |
|---|---|---|
| `no_phone_number` | The workspace owns no phone number, so there is nothing to call from. | Get a number under **Channels → Phone**. See [Get a number](/channels/phone-numbers). |
| `no_agent_deployed` | The caller-ID number has no agent deployed to it. The number exists; nothing is listening on it. | Deploy an agent to that number, then retry. |
| `agent_number_mismatch` | You passed an `agent_id`, and a **different** agent is deployed to the caller-ID number. `details.deployed_agent_id` names the one that is actually there. | See below. |
| `workspace_suspended` | Calling is paused on this workspace. | The owner resolves it on [Billing](/console/billing). |
| `voice_not_configured` | Voice is not configured on this deployment. | Contact support. |

`agent_number_mismatch` deserves a note, because the mental model behind it trips people up. **The agent that speaks on an outbound call is resolved from the caller-ID number, not from `agent_id`.** Passing `agent_id` does not choose the agent — it asserts which agent you expect, and the call fails if the assertion is wrong. That is a safety feature: it stops a code change that repointed a number from silently having the wrong agent phone your customers.

So there are two fixes and they are different actions: either send the `agent_id` from `details.deployed_agent_id` (you were wrong about which agent is there), or change which agent is deployed to that number (the deployment was wrong). Alternatively, pass `from_number` explicitly to select the number whose agent you want.

Two related failures that are **not** `412`:

- **`403` with `unknown_from_number`** — the `from_number` you passed is not one of this workspace's numbers.
- **`400` with `self_dial`**: the destination is one of your own numbers. Usually a test fixture that outlived its usefulness.
- **`403` with `destination_blocked` or `country_not_allowed`**: outbound calls aren't allowed to that range or country. `country_not_allowed` lists the allowed codes in `details.allowed_country_codes`.

### Plan, credits and wind-down

These depend on your workspace's billing state, not on the request. Retrying won't help; fixing it on [Billing](/console/billing) will.

| `details.reason` | Where | What it means | How to fix |
|---|---|---|---|
| `allowance_exhausted` | `POST /messages` | The plan's conversations are used up and there are no credits to pay for more. | Add credits, or upgrade. See [What happens when you run out](/operate/credits#what-happens-when-you-run-out). |
| `wallet_empty` | `POST /calls` | The plan's voice minutes are used up and there are no credits. | Add credits, or upgrade. |
| `channel_dormant` | `POST /messages`, `POST /calls` | Your plan no longer includes this channel, for example after moving to Free. | Upgrade within the hold period. See [The 7-day hold](/operate/plan-changes#the-7-day-hold). |

Alert on these separately from message failures: every request fails the same way until someone acts.

---

## 429 — three different limits

All three return `code: "rate_limited"`. `details.reason` tells them apart, and they need completely different responses.

### `rate_limited` — too many requests

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 120 requests/minute exceeded for this key.",
    "details": { "reason": "rate_limited", "limit": 120, "window_seconds": 60 },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

**What it means.** More than 120 requests in one minute **on this key**. The window is a fixed wall-clock minute, not a sliding one.

**How to fix.** There is **no `Retry-After` header** and no `X-RateLimit-Reset`. Because the window is a wall-clock minute, sleep to the next minute boundary:

```python
time.sleep(60 - (time.time() % 60) + 0.25)
```

Then fix the shape of the traffic. In order of effectiveness:

1. **Give the noisy job its own key.** The limit is per key, so a backfill on its own key cannot starve your customer notifications. This is the single highest-value change.
2. **Pace rather than burst.** Roughly two requests per second keeps you under the limit continuously instead of exhausting it in the first ten seconds of every minute.
3. **Read `X-RateLimit-Remaining`.** It is on every successful response. Slow down when it gets low, rather than waiting to be rejected. Note it is absent on errors — including on the `429` itself.

### `call_concurrency` — too many live calls

```json
{
  "error": {
    "code": "rate_limited",
    "message": "This workspace already has 10 calls in progress (limit 10). Try again shortly.",
    "details": { "reason": "call_concurrency", "limit": 10 },
    "request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4",
    "correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"
  }
}
```

**What it means.** Ten calls are already ringing or in progress **across the whole workspace**, regardless of which key placed them. Slowing your request rate does not help; you need a call to end.

**How to fix.** Queue outbound calls and drain the queue as `call.ended` events arrive, keeping at most ten in flight. A loop that fires 200 calls meets this on the eleventh. If you have hung calls inflating the count, `POST /calls/{call_id}/hangup` ends one, and its `ended: false` response tells you the call was already over.

### `daily_call_budget` — the daily outbound limit

**What it means.** The workspace has reached its daily outbound calling safety limit. `details` carries the calls and reserved minutes used so far, and the limits.

**How to fix.** Wait for the next day, and spread large call lists over several days. A [call campaign](/campaigns/overview) paces and schedules calls for you.

---

## Webhooks

### Signature mismatch

**What you see.** Your handler computes an HMAC that does not equal the `v1` value in `Amomic-Signature`, so every delivery is rejected. From our side, the endpoint accumulates failures and eventually disables itself.

Causes, in the order they actually occur:

**1. You verified a re-serialised body.** By far the most common. A JSON body-parser ran before your verifier, and you signed the re-serialised output. The signature covers the exact bytes we sent — compact separators, that key order, that whitespace. Read the raw body, verify, and parse only afterwards.

```javascript
// express.raw, not express.json
app.post("/hooks/amomic", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");    // exactly what was sent
  if (!verify(raw, req.get("Amomic-Signature") ?? "")) return res.status(401).end();
  const event = JSON.parse(raw);            // parse after verifying
  // …
});
```

**2. The signed string is assembled wrongly.** It is `"{t}.{body}"` — the timestamp from the header, a literal full stop, then the body. Not the body alone, not the body then the timestamp, and not a JSON object containing both.

**3. The secret is being decoded.** `whsec_…` is used as **raw UTF-8 bytes**, including the `whsec_` prefix. Do not base64-decode it, do not strip the prefix, and do not trim characters from it.

**4. The clock is wrong.** Verifiers reject a timestamp more than **5 minutes** from now. A server whose clock has drifted rejects perfectly good deliveries and, helpfully, will also fail every other TLS-adjacent thing it does. Check `date` on the box.

**5. You are comparing the wrong field.** Parse the header by splitting on `,` and then on the first `=`. Compare against `v1`. Use a constant-time comparison — a normal string equality leaks timing, and some libraries also throw on a length mismatch rather than returning false.

**6. A proxy modified the body.** Rarer, but real: a gateway that pretty-prints JSON, transcodes character encodings, or strips a trailing newline breaks the signature by definition. Test by logging the raw byte length you received and comparing it against `Content-Length`.

[Verifying signatures](/webhooks/signatures) has complete verifiers in both languages.

### The endpoint was auto-disabled

**What you see.** Your handler stops being called. No errors on your side, just silence. In the console, under **Developers → Webhooks**, the endpoint shows **Disabled** with the note *Disabled automatically after 15 consecutive failed deliveries.*

**What it means.** Fifteen consecutive **events** failed delivery. Note the unit: not fifteen HTTP attempts. Each event gets up to four attempts, so fifteen failed events can mean sixty requests to your endpoint. A failed **Test** counts too.

The retry rules that got you here:

- **2xx** — success, counter resets to zero.
- **4xx that is not 429** — no retry at all. A `400` or a `401` is your endpoint saying "this request is wrong", and repeating it would be rude. This is why a broken signature check disables an endpoint quickly.
- **429, any 5xx, connection errors, timeouts** — retried, up to four attempts.

**How to fix.** Fix the handler first, then click **Enable** on the endpoint. That resets the failure count and clears the note. Click **Test**, check **Deliveries** for a `200`, then **Replay** the deliveries it missed.

::: warning Events that fired while it was disabled are gone
A disabled endpoint receives nothing. Failed recorded deliveries can be inspected and replayed from **Developers → Webhooks**, but events created while the endpoint was disabled still need a reconciliation path — the scheduled [sync job](/recipes/sync-conversations) for conversations, `GET /calls/{call_id}` for call outcomes, and `GET /tickets/{ticket_id}` for tickets.
:::

To catch this before it costs you fifteen events, alert on **silence**: a handler that has received nothing for an hour on a workspace that normally sees traffic. That alert fires whether the cause is auto-disable, a DNS change, or an expired certificate.

### Duplicate events

Delivery is **at-least-once**. The same event can arrive more than once — a timeout on our side after your handler already committed, for instance. Deduplicate on the top-level envelope `id` (`evt_…`), which is also sent as the `Amomic-Event-Id` header. Note that `data.event_id` is a *different*, internal identifier; do not key your dedupe table on it.

### An event type you do not recognise

The console's **Test** button sends an ordinary signed event whose `type` is `ping`, which is not one of the twenty event types you can subscribe to. New event types may also be added. A handler with a `default` branch that logs and returns `200` handles both; a handler without one throws, returns 500, and starts the fifteen-event countdown.

---

## Symptoms that are not an error response

### A call stuck in `ringing`

**What you see.** Your database says a call has been `ringing` for an hour.

**What it means.** Almost never that the phone is still ringing. `POST /calls` returns `201` **as soon as the number is ringing** — that is an acknowledgement, not an outcome — and `ring_seconds` caps the actual ringing at 10 to 90 seconds, defaulting to 30. If your record still says `ringing`, your record is stale.

**How to fix.** In order:

1. **Ask the API.** `GET /calls/{call_id}` returns the current state. If it says `completed` or `failed`, your record never got updated, and the problem is on the path between us and your database.
2. **Check whether `call.ended` is arriving at all.** The usual cause of a stale record is a webhook endpoint that is failing or has been auto-disabled. Look at **Developers → Webhooks**.
3. **Check you are polling.** Code that treats the `201` as the end of the story never learns the outcome. The outcome comes from `GET /calls/{call_id}` or from `call.ended`, and one of the two must be wired up.
4. **Confirm with a hangup.** `POST /calls/{call_id}/hangup` returns `{"call_id": "…", "ended": false}` when the call was **not** ringing or in progress — which is a direct statement that the call is already over and your record is wrong.

Also make sure you are not switching exhaustively on `status`. It is typed as a plain string with no declared enum; a state you have not seen before must not throw. See [API versioning](/operate/versioning).

### The agent answers "I don't know" too often

**What you see.** The agent is polite and useless — it declines to answer things you are certain are in its knowledge base.

**What it means.** Almost always retrieval, not the model. The agent answers from what it can find, and if it cannot find the passage it will say so rather than invent one. That refusal is the desired behaviour; the problem is upstream of it.

**How to work through it:**

1. **Check the knowledge base has content.** Open the knowledge base and look at its sources. A source sitting in `error`, or one that finished with zero chunks, contributes nothing. Re-sync it and read the error text.
2. **Check the agent is using the knowledge base you think it is.** An agent can be attached to a different knowledge base than the one you have been editing.
3. **Check the change is saved.** Saved edits to an agent, and knowledge attached to it, reach customers straight away; there is no draft to publish first. If answers got worse after a change, compare versions and roll back. See [Versions & rollback](/build/versions).
4. **Ask the question the way a customer does.** Retrieval matches both meaning and words, but a document written in internal vocabulary — product codes, team names, an acronym nobody outside uses — will not match a customer's phrasing. The fix is usually to add the customer's words to the document, not to change the agent.
5. **Look for the answer actually being absent.** A surprising share of these end with someone discovering the policy was never written down anywhere. The agent was right.

Try questions in the [retrieval tester](/build/knowledge-retrieval#the-retrieval-tester), and test changes on the Test tab before you save them, reading the transcript rather than only the final answer. [Knowledge bases](/build/knowledge) and [Testing and publishing](/build/testing) cover both loops.

### A phone number rejected at validation

**What you see.** `400` with `Request validation failed.` and `details.errors` pointing at `to`, before anything is sent or dialled.

**What it means.** The number does not match `^\+?\d+$` at 6–20 characters: digits, with an optional single leading `+`, and nothing else.

| Value | Result |
|---|---|
| `+919876543210` | Valid |
| `919876543210` | Valid |
| `+91 98765 43210` | **Rejected** — spaces |
| `+91-98765-43210` | **Rejected** — hyphens |
| `(+91) 98765 43210` | **Rejected** — parentheses |
| `+91 (0) 9876543210` | **Rejected** — parentheses and spaces |

**How to fix.** Normalise before you send, not after the first failure. Most CRMs store numbers as a human typed them:

```python
import re

def normalise(raw: str) -> str:
    digits = re.sub(r"\D", "", raw)
    if len(digits) == 10:          # a bare Indian mobile number
        digits = f"91{digits}"
    return f"+{digits}"
```

Responses always come back normalised to `+` followed by digits, so the value you get back may not be byte-identical to what you sent. Store the normalised form.

Two related failures on `POST /calls` are *not* validation errors and carry no `details.reason`: `"That destination number isn't usable."` when the number can't be read as a real phone number, and `"That destination number is too short."` when it has fewer than 8 digits.

---

## Still stuck

Write to `support@amomic.in` with three things:

- **The `request_id`** from the error envelope. It identifies exactly one call.
- **The time, with its time zone**, the **key name**, and your **Organization ID** from [Settings](/console/settings#organization).
- **What you expected** and what happened instead.

If you cannot get a `request_id` because the request never returned one, send the `correlation_id` you set on `X-Correlation-Id`, or the timestamp and the endpoint. Check **Developers → Logs** first: your newest requests are there, and the answer often is too.

For a suspected security issue, use `support@amomic.in` instead. See [Security and data](/operate/security).

## Related

::: cards

::: card Errors
[Every code and reason](/api/errors) in one reference, with the full status map.
:::

::: card Limits and plans
[What is enforced](/operate/limits) — rate limits, concurrency, and every field bound.
:::

::: card Going live
[The pre-launch checklist](/operate/going-live) — most of this page, prevented.
:::

::: card API versioning
[Forward-compatible client code](/operate/versioning) — why a `default` branch matters.
:::

:::
