# API overview

> The base URL, the shape of every request, and the complete route table for the Amomic-AI REST API.

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

---

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](/start/first-api-call). This page is the map you come back to.

## Base URL

```endpoint
https://services.amomic.in/api/v1
```

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

```mock
{"component": "PlatformDiagram", "props": {"caption": "Your backend calls /api/v1; events come back to you over webhooks. Everything an agent is made of is configured in the console."}}
```

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

::: warning If you need something in the page
Use the [hosted web chat widget](/channels/web-chat) or [build your own interface](/channels/web-chat-custom-ui). 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.

```params
[
  {"name": "Authorization", "type": "header", "required": true, "description": "<code>Bearer amk_live_…</code>. Alternatively send <code>X-Api-Key: amk_live_…</code> — either is accepted, and <code>X-Api-Key</code> wins if you send both."},
  {"name": "Content-Type", "type": "header", "required": false, "description": "<code>application/json</code> on every request with a body. Every POST takes a JSON body (the campaign action routes need one too, for <code>expected_version</code>); none take form encoding or multipart."},
  {"name": "Idempotency-Key", "type": "header", "required": false, "description": "Honoured on five creates: <code>POST /messages</code>, <code>POST /calls</code>, <code>POST /tickets</code>, <code>POST /campaigns</code>, and <code>POST /apps/duesly/subscribers</code>. Makes a retry safe. See <a href=\"/api/idempotency\">Idempotency</a>."},
  {"name": "X-Correlation-Id", "type": "header", "required": false, "description": "Your 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:

::: codegroup

```bash cURL
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."}'
```

```javascript Node.js
const response = await fetch("https://services.amomic.in/api/v1/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": `order-${order.id}-shipped`,
    "X-Correlation-Id": traceId,
  },
  body: JSON.stringify({
    to: order.customerPhone,
    text: `Your order ${order.reference} has shipped.`,
  }),
});
```

```python Python
import os, requests

response = requests.post(
    "https://services.amomic.in/api/v1/messages",
    headers={
        "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
        "Idempotency-Key": f"order-{order.id}-shipped",
        "X-Correlation-Id": trace_id,
    },
    json={"to": order.customer_phone, "text": f"Your order {order.reference} has shipped."},
    timeout=30,
)
```

:::

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

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

Nine `code` values exist and no others can. [Errors](/api/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](/api/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](/api/discovery) |
| `/messages` | [Messages](/api/messages) |
| `/calls…` | [Calls](/api/calls) and [Call outcomes](/api/call-outcomes) |
| `/conversations…` | [Conversations](/api/conversations) |
| `/contacts…` | [Contacts](/api/contacts) |
| `/tickets…` | [Tickets](/api/tickets) |
| `/campaigns…` | [Campaigns](/api/campaigns); the two outcome routes are on [Call outcomes](/api/call-outcomes) |
| `/apps/duesly/…` | [Duesly (payments)](/api/apps-duesly) |

Three rows deserve a second look:

- **`GET /tickets/{ticket_id}` requires `tickets:write`.** There is no `tickets:read` scope. Granting a key the ability to read a ticket necessarily grants it the ability to open one. [Scopes](/api/scopes) explains the reasoning and what to do about it.
- **`POST /contacts` does not honour `Idempotency-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.csv` requires `campaigns: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 `/outcomes` and `/recipients` instead.

### The machine-readable version

```endpoint
GET /api/v1/openapi.json
```

An 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](/agents-setup).

## 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](/channels/web-chat) for in-page work.
- **`POST /messages` is still one recipient.** For a managed WhatsApp broadcast or call batch, use the [Campaigns API](/api/campaigns), which owns audience resolution, pacing, guardrails, scheduling, and per-recipient outcomes.
- **Individual messages and calls have no scheduling parameter.** There is no `send_at` on `/messages` or `/calls`; hold that request until you want it sent. Campaigns have their own schedule.
- **There is no media upload on messages.** `POST /messages` accepts 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 — `fetch` in Node or `requests` in Python is the whole dependency list, and `GET /openapi.json` generates 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 /messages` or message list; read message history through [conversations](/api/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 `live` versus `test`, and it follows the environment a key was issued in. You cannot obtain a test key that works against production.

## Next

::: cards

::: card Authentication
[How keys work](/api/authentication): the format, the one-time reveal, rolling, revoking, and the distinct causes of a 401 or 403.
:::

::: card Scopes
[What a key can do](/api/scopes): eleven scopes, the read-only default, and the endpoint matrix.
:::

::: card Errors
[One envelope, nine codes](/api/errors), and the `details.reason` catalogue to branch on.
:::

::: card Send something
[POST /messages](/api/messages): the endpoint most integrations exist for.
:::

:::
