# Conversations

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

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

---

```endpoint
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](/api/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

```params
[
  {"name": "limit", "type": "integer", "required": false, "default": 25, "description": "How many conversations to return. Minimum 1, maximum 100."},
  {"name": "cursor", "type": "string", "required": false, "default": "null", "description": "The <code>next_cursor</code> from the previous page. Omit for the first page."},
  {"name": "agent_id", "type": "string", "required": false, "default": "null", "description": "Return only conversations handled by this agent. Get ids from <a href=\"/api/discovery\">GET /agents</a>."}
]
```

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.

::: codegroup

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

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";

const params = new URLSearchParams({ limit: "50", agent_id: agentId });
const response = await fetch(`${BASE}/conversations?${params}`, {
  headers: { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` },
});

const { items, next_cursor } = await response.json();
```

```python Python
import os

import requests

BASE = "https://services.amomic.in/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}

page = requests.get(
    f"{BASE}/conversations",
    headers=HEADERS,
    params={"limit": 50, "agent_id": agent_id},
    timeout=30,
).json()

items, next_cursor = page["items"], page["next_cursor"]
```

:::

```json title="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](/api/contacts)), or the `conversation.started` [webhook](/webhooks/events) 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`](/webhooks/events) webhook for the message that carried it.

::: note `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

```endpoint
GET /api/v1/conversations/{conversation_id}
```

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

::: codegroup

```bash cURL
curl https://services.amomic.in/api/v1/conversations/con_01JQRS4T8XZ0AB1CD2EF3GH4 \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```javascript Node.js
const response = await fetch(`${BASE}/conversations/${conversationId}`, { headers: HEADERS });
if (response.status === 404) return null; // not found, or not in this workspace
const conversation = await response.json();
```

```python Python
response = requests.get(f"{BASE}/conversations/{conversation_id}", headers=HEADERS, timeout=30)
conversation = None if response.status_code == 404 else response.json()
```

:::

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

```endpoint
GET /api/v1/conversations/{conversation_id}/messages
```

```params
[
  {"name": "limit", "type": "integer", "required": false, "default": 100, "description": "How many messages to return. Minimum 1, maximum 200."},
  {"name": "cursor", "type": "string", "required": false, "default": "null", "description": "The <code>next_cursor</code> from the previous page — a <code>message_id</code>. 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.

```json title="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.

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

::: codegroup

```python 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"]
```

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";
const HEADERS = { Authorization: `Bearer ${process.env.AMOMIC_API_KEY}` };

async function syncSince(watermark) {
  let cursor = null;
  let newest = watermark;
  let done = false;

  while (!done) {
    const params = new URLSearchParams({ limit: "100" });
    if (cursor) params.set("cursor", cursor);

    const response = await fetch(`${BASE}/conversations?${params}`, { headers: HEADERS });
    if (!response.ok) throw new Error((await response.json()).error.message);
    const { items, next_cursor } = await response.json();

    for (const conversation of items) {
      const updated = conversation.updated_at;
      if (watermark && updated && updated <= watermark) {
        done = true; // ordered desc — the rest is already synced
        break;
      }
      await storeConversation(conversation);
      await storeTranscript(conversation.conversation_id);
      if (updated && (!newest || updated > newest)) newest = updated;
    }

    if (!next_cursor) break;
    cursor = next_cursor;
  }

  return newest;
}
```

:::

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](/webhooks/events) 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

```responses
[
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "Request validation failed — a `limit` outside its bounds is the usual cause. `details.errors` names the parameter."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "Key missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. `details.reason` distinguishes all six."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended for billing. Every key in it is refused until billing is restored."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "The key lacks `conversations:read`. `details.required_scope` names it."},
  {"status": 404, "code": "not_found", "reason": "", "description": "`Conversation not found.` — the id does not exist or belongs to another workspace. Returned by both the single-conversation and messages endpoints."},
  {"status": 412, "code": "failed_precondition", "reason": "unsupported_resource", "description": "Conversations are not available on this deployment. A configuration state, not a transient one — do not retry."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 requests per minute per API key exceeded. A full-history backfill will hit this; pace the walk."}
]
```

::: tip 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](/api/rate-limits).
:::

## Related

::: cards

::: card Pagination
[The cursor contract](/api/pagination) in full — when `next_cursor` is set and why a short page ends the walk.
:::

::: card Sync conversations
[The complete recipe](/recipes/sync-conversations), including scheduling, retries, and where webhooks fit.
:::

::: card Conversations and contacts
[The model behind the data](/concepts/conversations) — what a conversation is, and how customers are identified across channels.
:::

::: card Webhook events
[`conversation.started`](/webhooks/events) for reacting to a new conversation instead of polling.
:::

:::
