API overview
The base URL, the shape of every request, and the complete route table for the Amomic-AI REST API.
The Amomic-AI API lets your own systems send messages, place AI calls, read conversations, open and update tickets, read and upsert contacts, run WhatsApp and call campaigns, and drive the Duesly payments app. It is a deliberately flat REST surface with one error envelope and one authentication mechanism.
If you have not made a request yet, start with Your first API call. This page is the map you come back to.
Base URL #
Every path on this page is relative to that. There is no other host, no /api/v2, no Accept-Version header, and no content negotiation — see API versioning for how changes are handled instead.
Your key carries an environment. A live key (amk_live_…) is the one that works against this host. A test key used here fails with 401 and details.reason of wrong_environment — a distinct, loud error rather than a silent no-op. Authentication covers the split.
What the API is for #
The API is the operational surface. It drives agents you have already built:
- Send a WhatsApp message — free-form text inside the 24-hour window, or an approved template outside it.
- Call a number and have an agent hold the conversation, then read the outcome.
- Read conversations and their transcripts, whichever channel they happened on.
- Open a support ticket from your own system, read it back, and move its status, priority, category, or tags.
- Read and upsert contacts so a person your CRM knows about is the same person our inbox knows about.
- Run campaigns: create a WhatsApp broadcast or a call batch as a draft, check who it would reach, launch, pause, resume, cancel, and read per-recipient results and call outcomes.
- Drive Duesly: put customers on recurring plans, list dues, and record payments collected outside the gateway.
It is not the agent-building surface. Agents, versions, knowledge bases, tools, deployments, phone numbers, and templates are configured in the console and have no public REST equivalent. That boundary is intentional: the things you change once a quarter live where humans can review them, and the things you call ten thousand times a day live here.
Server-side only #
/api/v1 sends no CORS headers at all. Not a restrictive Access-Control-Allow-Origin — none. Preflight requests are not answered. A browser cannot successfully call this API, and that is the design, not a gap.
The reason is the key itself. API keys are workspace-wide and carry every scope you granted them; there is no per-user, per-agent, or per-origin key. A key placed in front-end JavaScript is readable by anyone who opens your bundle, and the blast radius of that leak is your entire workspace. Removing CORS removes the temptation.
If you need something in the page
Use the hosted web chat widget or build your own interface. Both use browser-safe, origin-bound visitor sessions — no API key involved.
The shape of every request #
Four things make up a request. Only the first is always required.
AuthorizationheaderrequiredBearer amk_live_…. Alternatively send X-Api-Key: amk_live_… — either is accepted, and X-Api-Key wins if you send both.
Content-Typeheaderoptionalapplication/json on every request with a body. Every POST takes a JSON body (the campaign action routes need one too, for expected_version); none take form encoding or multipart.
Idempotency-KeyheaderoptionalHonoured on five creates: POST /messages, POST /calls, POST /tickets, POST /campaigns, and POST /apps/duesly/subscribers. Makes a retry safe. See Idempotency.
X-Correlation-IdheaderoptionalYour own trace id. Echoed back on the response and stored in your workspace request log, so one id joins your logs to ours. Generated for you if you omit it.
A minimal request looks like this:
curl -X POST https://services.amomic.in/api/v1/messages \-H "Authorization: Bearer $AMOMIC_API_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: order-4821-shipped" \-H "X-Correlation-Id: trace-9f2c1d" \-d '{"to": "+919876543210", "text": "Your order AM-4821 has shipped."}'
What comes back #
| Header | When | Meaning |
|---|---|---|
X-Correlation-Id |
every response | Your X-Correlation-Id echoed, or one we generated. |
X-RateLimit-Limit |
successful responses only | Requests allowed per minute for this key. |
X-RateLimit-Remaining |
successful responses only | Requests left in the current minute. |
There is no X-Request-Id response header. The request id appears inside error envelopes as error.request_id, and nowhere else. There is no Retry-After and no X-RateLimit-Reset — Rate limits explains how to back off without them.
Successful responses are 200, except the six creates, which are 201: POST /messages, /calls, /contacts, /tickets, /campaigns, and /apps/duesly/subscribers.
Every non-2xx response uses one envelope:
{"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"}}
Nine code values exist and no others can. Errors has the full map and the complete details.reason catalogue.
Ids #
Every id we mint is a prefix, an underscore, and a body. The prefix tells you what you are holding even when it is out of context in a log line.
| Prefix | What it identifies | Where you see it |
|---|---|---|
con_ |
A conversation | conversation_id |
msg_ |
A chat message | message_id on transcript rows |
call_ |
A call | call_id |
tkt_ |
A ticket | ticket_id |
cmp_ |
A campaign | campaign_id |
sub_ |
A Duesly subscriber | subscriber_id |
AM-XXXXXXXX |
A ticket's human reference | public_ref — the one you quote to a customer |
req_ |
One API request | error.request_id |
cor_ |
One trace | error.correlation_id, X-Correlation-Id |
evt_ |
One webhook event. It keeps the same id across retries and replays, so it is your dedupe key | The webhook envelope id and Amomic-Event-Id |
whd_ |
One delivery attempt of an event | The console's delivery log |
amk_ |
An API key token | Shown once, at creation |
whsec_ |
A webhook signing secret | Shown once, at endpoint creation |
wamid. |
A WhatsApp message | message_id from POST /messages — WhatsApp's format, not ours |
Most of these bodies are ULIDs: 26 characters of Crockford base32, time-ordered, so sorting the strings sorts by creation time. That property is load-bearing — the message cursor in Pagination is just a message_id.
Treat every id as an opaque string. Do not parse the body, and do not assume a length: tkt_ and evt_ ids are hex, not ULIDs, and wamid. belongs to WhatsApp.
Timestamps #
Two shapes exist, and confusing them is a real source of bugs.
| Where | Shape | Example |
|---|---|---|
REST resources on /api/v1 |
ISO 8601 in UTC. Most carry microseconds and +00:00; some resources end in Z instead |
2026-10-14T05:50:12.418903+00:00 |
The webhook envelope's created_at |
ISO 8601 to the second with a literal Z |
2026-10-14T05:50:12Z |
All of them are UTC. All parse with any conforming ISO 8601 parser, but a hand-rolled regex or a strptime format string written against one shape will fail on another.
Every timestamp field is nullable. A value that cannot be formatted comes back as null rather than raising, so created_at: null means "not recorded", not "epoch".
The route table #
Thirty-three operations, and that is the entire public surface. Thirty-two sit behind an API key; the OpenAPI document needs nothing.
| Method | Path | Scope | Idempotent | Success |
|---|---|---|---|---|
| GET | /openapi.json |
none (unauthenticated) | – | 200 |
| GET | /ping |
any valid key | – | 200 |
| GET | /channels |
any valid key | – | 200 |
| POST | /messages |
messages:send |
yes | 201 |
| POST | /calls |
calls:write |
yes | 201 |
| GET | /calls/{call_id} |
calls:write |
– | 200 |
| POST | /calls/{call_id}/hangup |
calls:write |
– | 200 |
| GET | /conversations |
conversations:read |
– | 200 |
| GET | /conversations/{conversation_id} |
conversations:read |
– | 200 |
| GET | /conversations/{conversation_id}/messages |
conversations:read |
– | 200 |
| GET | /agents |
agents:read |
– | 200 |
| GET | /contacts |
contacts:read |
– | 200 |
| GET | /contacts/lookup |
contacts:read |
– | 200 |
| GET | /contacts/{contact_id} |
contacts:read |
– | 200 |
| POST | /contacts |
contacts:write |
converges | 201 |
| POST | /tickets |
tickets:write |
yes | 201 |
| GET | /tickets/{ticket_id} |
tickets:write |
– | 200 |
| PATCH | /tickets/{ticket_id} |
tickets:write |
version guard | 200 |
| POST | /campaigns |
campaigns:write |
yes | 201 |
| GET | /campaigns |
campaigns:read |
– | 200 |
| GET | /campaigns/{campaign_id} |
campaigns:read |
– | 200 |
| POST | /campaigns/{campaign_id}/evaluate |
campaigns:read |
– | 200 |
| POST | /campaigns/{campaign_id}/launch |
campaigns:write |
version guard | 200 |
| POST | /campaigns/{campaign_id}/pause |
campaigns:write |
version guard | 200 |
| POST | /campaigns/{campaign_id}/resume |
campaigns:write |
version guard | 200 |
| POST | /campaigns/{campaign_id}/cancel |
campaigns:write |
version guard | 200 |
| GET | /campaigns/{campaign_id}/recipients |
campaigns:read |
– | 200 |
| GET | /campaigns/{campaign_id}/outcomes |
campaigns:read |
– | 200 |
| GET | /campaigns/{campaign_id}/outcomes.csv |
campaigns:write |
– | 200 (CSV) |
| GET | /apps/duesly/subscribers |
campaigns:read |
– | 200 |
| POST | /apps/duesly/subscribers |
campaigns:write |
yes | 201 |
| GET | /apps/duesly/dues |
campaigns:read |
– | 200 |
| POST | /apps/duesly/dues/{due_id}/paid |
campaigns:write |
state guard | 200 |
Grouped by what you are doing:
| Area | Page |
|---|---|
/ping, /channels, /agents |
Agents & channels |
/messages |
Messages |
/calls… |
Calls and Call outcomes |
/conversations… |
Conversations |
/contacts… |
Contacts |
/tickets… |
Tickets |
/campaigns… |
Campaigns; the two outcome routes are on Call outcomes |
/apps/duesly/… |
Duesly (payments) |
Three rows deserve a second look:
GET /tickets/{ticket_id}requirestickets:write. There is notickets:readscope. Granting a key the ability to read a ticket necessarily grants it the ability to open one. Scopes explains the reasoning and what to do about it.POST /contactsdoes not honourIdempotency-Key. It is naturally convergent: it matches on email, then phone, so a retry resolves to the same contact. It is the one create where the header is ignored.GET /campaigns/{campaign_id}/outcomes.csvrequirescampaigns:write, although it is a read. The file carries every column of the uploaded list, which the per-recipient read does not, so a reporting key reads/outcomesand/recipientsinstead.
The machine-readable version #
/api/v1/openapi.jsonAn OpenAPI 3.1 document describing the thirty-two keyed operations, fetchable with no key at all. List endpoints are typed as a page of items without an item schema, so use these pages for the item fields. Feed it to a client generator, or hand it to a coding agent alongside these docs.
What this API deliberately does not do #
This is scope, not a roadmap. Building against an assumption in this list will not work today and is not waiting on a release.
- It is not callable from a browser. No CORS headers, no preflight. Use the widget for in-page work.
POST /messagesis still one recipient. For a managed WhatsApp broadcast or call batch, use the Campaigns API, which owns audience resolution, pacing, guardrails, scheduling, and per-recipient outcomes.- Individual messages and calls have no scheduling parameter. There is no
send_aton/messagesor/calls; hold that request until you want it sent. Campaigns have their own schedule. - There is no media upload on messages.
POST /messagesaccepts text and approved templates. There is no attachment field and no upload endpoint to put a file behind one. - There are no SDKs. No official client library in any language. The API is plain HTTP and JSON on purpose —
fetchin Node orrequestsin Python is the whole dependency list, andGET /openapi.jsongenerates a client if you want one. - There is no key or webhook management API. Keys and webhook endpoints are created, rolled, and revoked in the console by an admin. There is no public route that mints a credential.
- There is no read side for messages. No
GET /messagesor message list; read message history through conversations. Contacts do have list, lookup, and get routes. - Updates are deliberately narrow.
PATCH /tickets/{ticket_id}can change status, priority, category, and tags with optimistic version checking. Other public resources have no general update or delete routes. - There is no sandbox mode. The only split is
liveversustest, and it follows the environment a key was issued in. You cannot obtain a test key that works against production.
Next #
Authentication
How keys work: the format, the one-time reveal, rolling, revoking, and the distinct causes of a 401 or 403.
Scopes
What a key can do: eleven scopes, the read-only default, and the endpoint matrix.
Errors
One envelope, nine codes, and the details.reason catalogue to branch on.
Send something
POST /messages: the endpoint most integrations exist for.