Skip to content

Conversations

List conversations, read one, and pull its transcript — the read side of everything your agents say and hear.

Open the Inbox
GET/api/v1/conversations

Every 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 25

How many conversations to return. Minimum 1, maximum 100.

cursorstringoptionaldefault null

The next_cursor from the previous page. Omit for the first page.

agent_idstringoptionaldefault null

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

Shell
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"
200 OK
{
"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 #

GET/api/v1/conversations/{conversation_id}

Returns exactly one conversation object, same shape as a list item. No query parameters.

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

GET/api/v1/conversations/{conversation_id}/messages
limitintegeroptionaldefault 100

How many messages to return. Minimum 1, maximum 200.

cursorstringoptionaldefault null

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

200 OK
{
"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:

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

Python
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"]:
return
params["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.

Python
import os
import requests
BASE = "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, False
while 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 old
break
store_conversation(conversation)
store_transcript(conversation["conversation_id"])
if updated is not None and (newest is None or updated > newest):
newest = updated
if not body["next_cursor"]:
break
cursor = body["next_cursor"]
return newest
def store_transcript(conversation_id: str) -> None:
cursor = None
while 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_id
if not page["next_cursor"]:
return
cursor = 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_at changed 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 #

400invalid_argumentRequest validation failed — a `limit` outside its bounds is the usual cause. `details.errors` names the parameter.
401unauthenticatedKey missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. `details.reason` distinguishes all six.
403permission_deniedworkspace_suspendedThe workspace is suspended for billing. Every key in it is refused until billing is restored.
403permission_deniedmissing_scopeThe key lacks `conversations:read`. `details.required_scope` names it.
404not_found`Conversation not found.` — the id does not exist or belongs to another workspace. Returned by both the single-conversation and messages endpoints.
412failed_preconditionunsupported_resourceConversations are not available on this deployment. A configuration state, not a transient one — do not retry.
429rate_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.

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.