Conversations
List conversations, read one, and pull its transcript — the read side of everything your agents say and hear.
/api/v1/conversationsEvery exchange between a customer and one of your agents — web chat, WhatsApp, a phone call — becomes a conversation. These three endpoints are how your own systems read them.
When you use this #
Three shapes of work send people here. You are syncing conversations into a warehouse or a CRM so support volume shows up next to revenue. You are enriching a record — a ticket, a lead, an account — with what was actually said. Or you are auditing, because someone asked what the agent told a customer on Tuesday and "check the inbox" is not an acceptable answer at scale.
The surface is read-only and deliberately small:
| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/conversations |
List conversations, newest activity first. |
GET |
/api/v1/conversations/{conversation_id} |
Read one conversation's summary record. |
GET |
/api/v1/conversations/{conversation_id}/messages |
Read the transcript, oldest turn first. |
All three require the conversations:read scope, which the Create an API key dialog ticks by default. See Scopes.
There is no way to create a conversation, post into one, close one, or delete one over this API. Replying is what a deployed agent does; the API reads.
Listing conversations #
limitintegeroptionaldefault 25How many conversations to return. Minimum 1, maximum 100.
cursorstringoptionaldefault nullThe next_cursor from the previous page. Omit for the first page.
agent_idstringoptionaldefault nullReturn only conversations handled by this agent. Get ids from GET /agents.
Conversations come back ordered by most recent activity first — newest updated_at at the top. That ordering is what makes an incremental sync possible, and it is also what makes a naive full walk unstable: a conversation that receives a new message while you are paginating moves to the front, behind your cursor.
curl -G https://services.amomic.in/api/v1/conversations \-H "Authorization: Bearer $AMOMIC_API_KEY" \--data-urlencode "limit=50" \--data-urlencode "agent_id=bot_01JQRS4T8XZ0AB1CD2EF3GH4"
{"items": [{"conversation_id": "con_01JQRS4T8XZ0AB1CD2EF3GH4","channel": "whatsapp","agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4","status": "open","customer": {"phone": "919876543210","name": "Asha Menon"},"message_count": 12,"preview": "Thanks, Thursday morning works. Please ask the technician to call from the gate.","created_at": "2026-10-14T04:11:18.220914+00:00","updated_at": "2026-10-14T04:32:55.118307+00:00","acquisition": {"ctwa": {"source_type": "ad","source_id": "120214567890123456","source_url": "https://www.instagram.com/p/DAbC123xYz/","headline": "AC service from ₹499","body": "Book a certified Veloria technician in 2 taps.","media_type": "image","ctwa_clid": "ARAkLm9Xq2zP0v","captured_at": "2026-10-14T04:11:18.402117+00:00"}}}],"next_cursor": "2026-10-14T04:32:55.118307+00:00"}
The conversation object #
| Field | Type | Notes |
|---|---|---|
conversation_id |
string | con_…. The handle for the other two endpoints. |
channel |
string | Where the conversation happened — web, whatsapp, or voice. Falls back to web when unset. |
agent_id |
string | null | The agent that handled it. |
status |
string | Every conversation reports open today; nothing closes one. Do not build logic on this field. |
customer |
object | Free-form. Populated from whatever identity the channel gave you. Keys you will see include name, display_name, phone, email and contact_id. Treat every key as optional. |
message_count |
integer | Turns in the transcript, defaulting to 0. Counts customer and agent turns only — see below. |
preview |
string | null | The first 140 characters of the last real message. Enough to render a list row without a second request. |
created_at |
string | null | ISO 8601 with offset, for example 2026-08-01T09:41:18.220914+00:00. |
updated_at |
string | null | Same format. Moves on every new message, including internal trail entries. Also, literally, the value used as the list cursor. |
acquisition |
object | null | Which ad started this conversation, when one did. null otherwise. See below. |
Nothing else is returned. Internal routing fields exist on the record and are deliberately not projected. To correlate a conversation with something in your own system, use customer (its contact_id works with Contacts), or the conversation.started webhook at the moment the conversation begins.
acquisition: the ad that brought them in #
When a customer starts a WhatsApp chat by tapping a Click-to-WhatsApp ad, the ad is recorded on the conversation as acquisition.ctwa. It is written once, from the first ad referral the conversation sees, and never changed: a returning customer who later taps a second ad still belongs to the first one.
| Field | Notes |
|---|---|
source_type |
What was tapped, as WhatsApp reports it, for example ad or post. |
source_id |
The ad's id. |
source_url |
The ad's or post's URL. |
headline, body |
The ad's text. |
media_type |
image or video. |
ctwa_clid |
The click id, for reporting conversions back to the ad platform. |
captured_at |
When the referral was recorded. |
A key is present only when it has a value. Web chat and voice conversations, and WhatsApp chats that did not start from an ad, have acquisition: null. The same referral arrives on the message.received webhook for the message that carried it.
`customer` is as good as the channel it came from
On WhatsApp you get an identity from the platform. On web chat you get whatever the lead form collected — and nothing at all if the visitor never filled one in. On voice you get the caller's number. Write your code so an empty customer object is an ordinary case, not an exception.
Reading one conversation #
/api/v1/conversations/{conversation_id}Returns exactly one conversation object, same shape as a list item. No query parameters.
curl https://services.amomic.in/api/v1/conversations/con_01JQRS4T8XZ0AB1CD2EF3GH4 \-H "Authorization: Bearer $AMOMIC_API_KEY"
An id that does not exist, or one that belongs to a different workspace, returns the same 404 with the message Conversation not found. The two cases are indistinguishable on purpose: telling you that an id exists but is not yours would leak the existence of other workspaces' data.
Reading the transcript #
/api/v1/conversations/{conversation_id}/messageslimitintegeroptionaldefault 100How many messages to return. Minimum 1, maximum 200.
cursorstringoptionaldefault nullThe next_cursor from the previous page — a message_id. Omit for the first page.
Messages come back oldest first, which is reading order. Paginating forward walks the conversation from its beginning to its end.
{"items": [{"message_id": "msg_01JQRS4T8XZ0AB1CD2EF3GH4","role": "user","text": "My split AC is dripping water inside the room since yesterday.","created_at": "2026-10-14T04:11:18.220914+00:00"},{"message_id": "msg_01JQRS4T8XZ0AB1CD2EF3GH5","role": "model","text": "Sorry about that, Asha. A blocked drain pipe is the usual cause. Your AMC covers a free visit. Shall I book a technician for Thursday?","created_at": "2026-10-14T04:11:23.884120+00:00"}],"next_cursor": null}
| Field | Type | Notes |
|---|---|---|
message_id |
string | msg_…. Time-ordered, so sorting by it is sorting chronologically. Also the pagination cursor. |
role |
string | user (the customer), model (the agent) or human (a member of your team replying from the console). Defaults to user when unset. Treat any other value as a turn to show, not an error. |
text |
string | null | The turn's text. null for a turn with no text representation. |
created_at |
string | null | ISO 8601 with offset. |
Message metadata is not exposed. Neither are the knowledge sources behind an answer.
What is left out of the transcript #
Internally a conversation also carries an operational trail of what the agent was doing between turns: looking something up, running a tool, playing a hold line on a call. Your staff see it in the inbox as a dashed rail, because when an answer looks wrong that trail is how you find out why.
The public transcript filters those rows out. What you get is what the customer said, what the agent said, and what your team said. message_count on the conversation object excludes the trail too, so it is the number of items the transcript should add up to.
Check the count when you page a transcript
The trail is filtered out of each page after the page is read, so a page can come back with fewer items than limit even though more turns follow, and then carry no next_cursor. Until that changes, ask for limit=200 and compare the number of items you received with the conversation's message_count. If you have fewer, the transcript is longer than what you were given.
That is the right default for a transcript you are storing, showing to a customer, or feeding to your own analytics — the trail is diagnostic detail, not conversation. It also means you should not expect the API transcript to be character-identical to the inbox view.
A bad id is a 404, not an empty page #
The messages endpoint loads the conversation before it loads any messages. A conversation_id that does not exist, or belongs to another workspace, returns 404 not_found with Conversation not found. — never 200 with an empty items array.
This matters more than it looks. An empty page from a mistyped id is the kind of bug that silently produces an empty report for three weeks. Here, a typo fails on the first call.
An empty items array therefore means exactly one thing: this conversation genuinely has no messages yet.
Pagination #
Both list endpoints use the same envelope:
{ "items": [ … ], "next_cursor": "…" }
The rule is short and worth internalising: next_cursor is non-null only when the page came back full. A page with fewer items than your limit ends the walk. There is no total count, no has_more, no page numbers, and no cursor for going backwards.
def walk(path: str, params: dict | None = None):params = dict(params or {})while True:page = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=30).json()yield from page["items"]if not page["next_cursor"]:returnparams["cursor"] = page["next_cursor"]
Treat the cursor as opaque and pass it back unmodified. Do not construct one, do not parse one, and do not persist one as a long-lived bookmark — persist a timestamp from the data instead, which is what the sync below does. Pagination covers the envelope in full.
Worked example: sync new conversations since the last run #
The job you will actually write. It runs on a schedule, pulls only what changed, and is safe to run twice.
The mechanism: conversations are ordered by updated_at descending, so a fresh walk from page one gives you the most recently active first. Keep a watermark — the highest updated_at you have processed — and stop walking the moment you reach a conversation at or below it. Everything after that point you have already seen.
import osimport requestsBASE = "https://services.amomic.in/api/v1"HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}def sync_since(watermark: str | None) -> str | None:"""Pull conversations updated since `watermark`. Returns the new watermark."""cursor, newest, done = None, watermark, Falsewhile not done:page = requests.get(f"{BASE}/conversations",headers=HEADERS,params={"limit": 100, **({"cursor": cursor} if cursor else {})},timeout=30,)page.raise_for_status()body = page.json()for conversation in body["items"]:updated = conversation["updated_at"]if watermark and updated is not None and updated <= watermark:done = True # ordered desc — everything after this is oldbreakstore_conversation(conversation)store_transcript(conversation["conversation_id"])if updated is not None and (newest is None or updated > newest):newest = updatedif not body["next_cursor"]:breakcursor = body["next_cursor"]return newestdef store_transcript(conversation_id: str) -> None:cursor = Nonewhile True:page = requests.get(f"{BASE}/conversations/{conversation_id}/messages",headers=HEADERS,params={"limit": 200, **({"cursor": cursor} if cursor else {})},timeout=30,).json()upsert_messages(conversation_id, page["items"]) # keyed on message_idif not page["next_cursor"]:returncursor = page["next_cursor"]
Four details that make it correct rather than nearly correct:
- Upsert on
message_id, do not append. A conversation you synced yesterday can gain turns today and will come back again. The message ids are stable, so an upsert makes the whole job idempotent. - Re-read the transcript for any conversation that moved.
updated_atchanged because something was said; you do not know which turn without reading. - Keep the watermark strictly from the data, not from your own clock. Timestamps here are server-side; your clock is not.
- Parse timestamps before you compare them. They are ISO 8601 in
+00:00, usually with microseconds. A value that falls on a whole second is written without the fraction, and then a plain string comparison puts it in the wrong place. The examples compare strings for brevity; parse in production.
If you need to know about a conversation the moment it starts rather than on a schedule, the conversation.started webhook pushes it. There is no event when a conversation goes quiet, so the sync above is still how you pick up later turns. The usual production shape is both: the webhook for reaction, this sync for completeness.
Failures #
invalid_argumentRequest validation failed — a `limit` outside its bounds is the usual cause. `details.errors` names the parameter.unauthenticatedKey missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. `details.reason` distinguishes all six.permission_deniedworkspace_suspendedThe workspace is suspended for billing. Every key in it is refused until billing is restored.permission_deniedmissing_scopeThe key lacks `conversations:read`. `details.required_scope` names it.not_found`Conversation not found.` — the id does not exist or belongs to another workspace. Returned by both the single-conversation and messages endpoints.failed_preconditionunsupported_resourceConversations are not available on this deployment. A configuration state, not a transient one — do not retry.rate_limitedrate_limited120 requests per minute per API key exceeded. A full-history backfill will hit this; pace the walk.Backfills need pacing
A first-run backfill of a busy workspace makes one request per page of conversations plus one per page of transcript. At 120 requests per minute per key that is the constraint you will meet first. Sleep between pages, or give the backfill its own API key so it cannot starve your production traffic. See Rate limits.
Related #
Pagination
The cursor contract in full — when next_cursor is set and why a short page ends the walk.
Sync conversations
The complete recipe, including scheduling, retries, and where webhooks fit.
Conversations and contacts
The model behind the data — what a conversation is, and how customers are identified across channels.
Webhook events
conversation.started for reacting to a new conversation instead of polling.