Skip to content

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.

Open the request log

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:

Shell
curl https://services.amomic.in/api/v1/ping \
-H "Authorization: Bearer $AMOMIC_API_KEY"
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.

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.

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.

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.

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.


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`."withdetails.reasonofempty_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. 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.

"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.

"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.

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 covers the rule in full.

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.

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, 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. 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.
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.
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 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.
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.

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

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

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

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.
  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, and test changes on the Test tab before you save them, reading the transcript rather than only the final answer. Knowledge bases and Testing and publishing 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.
  • 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.

Errors

Every code and reason in one reference, with the full status map.

Limits and plans

What is enforced — rate limits, concurrency, and every field bound.

Going live

The pre-launch checklist — most of this page, prevented.

API versioning

Forward-compatible client code — why a default branch matters.