> ## Documentation Index
> Fetch the complete documentation index at: https://docs.impellabs.tech/llms.txt
> Use this file to discover all available pages before exploring further.

# Conversations

> Reading a merchant's threads, replying into them, and taking one off the assistant.

We are the system of record for a merchant's conversations. Your product reads
them, replies into them, and decides when a person should take over.

<Note>
  These routes need `messages:read` and `messages:write`. They are deliberately
  **not** covered by `assistants:write` — a key issued to edit a prompt should not
  thereby be able to message a merchant's customers.
</Note>

<Info>
  **A conversation exists whenever a customer writes**, whether or not an
  assistant is going to answer. `conversation.created` fires for a number that is
  still awaiting activation, for a merchant with no live assistant, and for one
  whose allowlist excludes the sender.

  A thread exists because somebody wrote to it. That is a different question from
  whether anything is going to reply, and you can build an inbox on the first
  without taking a position on the second.
</Info>

## Reading

```http theme={null}
GET /api/v1/partner/tenants/{ref}/conversations/
GET /api/v1/partner/tenants/{ref}/conversations/{id}/
GET /api/v1/partner/tenants/{ref}/conversations/{id}/messages/?limit=50&after={message_id}
```

Messages page oldest-first on a stable cursor.

```json theme={null}
{ "object": "list", "data": [ … ], "has_more": true, "next_cursor": "…" }
```

`limit` is 1–100, default 50. A cursor naming nothing in this conversation is a
**400**, not an empty page — silently returning the first page would make a
paging client loop forever.

### The message shape

```json theme={null}
{
  "id": "…",
  "object": "message",
  "conversation": "…",
  "role": "user",
  "type": "text",
  "text": "Do you deliver to Kochi?",
  "created_at": "2026-09-07T10:12:00Z",
  "channel": {
    "name": "whatsapp",
    "message_id": "wamid.HBg…",
    "from": "+919876543210",
    "media": { "mime": "audio/ogg", "transcription": "…" },
    "template": { "name": "order_update" }
  }
}
```

<Warning>
  **`role` has three values and the third one is the point.** `user` is the
  customer, `assistant` is the AI, and `human` is a person — a salesperson
  replying from the WhatsApp Business App, or your own team through this API.

  An inbox that renders `assistant` and `human` identically is an inbox showing
  its operator something untrue.
</Warning>

Never present: prompts, retrieval evidence, token counts, internal reply-button
ids, or Meta media ids — the last are useless without the merchant's own access
token anyway.

## Sending

```http theme={null}
POST /api/v1/partner/tenants/{ref}/conversations/{id}/messages/send/
```

```json theme={null}
{ "type": "text", "text": "Yes — next-day delivery to Kochi.", "reply_to": "wamid.…" }
```

<ParamField body="type" type="string" default="text">
  `text`, `template`, `image`, `video`, `audio`, `document`, `interactive`,
  `location` or `reaction`.
</ParamField>

<ParamField body="reply_to" type="string">
  A `channel.message_id` from this thread. WhatsApp draws it as a quoted reply,
  which is how an answer to a question asked four messages ago stops reading like
  a non-sequitur.
</ParamField>

| `type`                              | Fields                                                     |
| ----------------------------------- | ---------------------------------------------------------- |
| `text`                              | `text`                                                     |
| `template`                          | `template_name`, `lang` (default `en_US`), `body_params[]` |
| `image` `video` `audio` `document`  | `link` **or** `media_id`, `caption`, `filename`            |
| `interactive` `location` `reaction` | `payload` — the Cloud API object                           |

Prefer `link` over `media_id` wherever a public URL exists: Meta fetches and
caches by URL, so one catalogue photo sent to a thousand customers is fetched
once instead of uploaded once and served a thousand times.

**201** returns the stored message and the conversation. It is recorded as
`role: "human"` and fires `message.sent` like any other outbound message.

### Two failures to handle

<AccordionGroup>
  <Accordion title="422 outside_customer_service_window">
    The 24-hour window has closed and this is not a template.

    ```json theme={null}
    { "error": {
        "code": "outside_customer_service_window",
        "message": "The 24-hour customer service window is closed. Send an approved template instead.",
        "window": { "open": false, "last_inbound_at": "…", "expires_at": "…" }
    } }
    ```

    Checked **before** Meta is called, so you get a named condition and a remedy
    rather than an unmapped provider error. Read `window.expires_at` off the
    conversation and show a countdown instead of discovering the limit by failing.
  </Accordion>

  <Accordion title="502 channel_rejected">
    Meta refused the message. Their words are in the body. Common causes: a template
    that is not approved yet, a number that has been blocked by the recipient, or a
    media URL Meta could not fetch.
  </Accordion>
</AccordionGroup>

## Messaging someone who has not written

The route above needs a thread the customer started, which is the wrong shape
for a broadcast, an invoice, a quotation, or "message this lead".

```http theme={null}
POST /api/v1/partner/tenants/{ref}/messages/send/
```

```json theme={null}
{
  "to": "+919876543210",
  "type": "template",
  "template_name": "order_update",
  "lang": "en_US",
  "body_params": ["Priya"],
  "whatsapp_number": "106540352242922"
}
```

<ParamField body="to" type="string" required>
  The customer's number in any dialable form. We normalise it.
</ParamField>

<ParamField body="whatsapp_number" type="string">
  Which of the merchant's numbers to send from. Defaults to their primary — worth
  naming explicitly once a merchant has more than one, so a support message does
  not leave from the marketing line.
</ParamField>

It **creates the conversation, or reuses the open one** for that number, so the
customer's reply lands in the thread your CRM already knows about rather than
beside it. The response carries both, exactly as the conversation route does.

<Warning>
  The 24-hour window still applies. A non-template to somebody who has not written
  in 24 hours is refused with the same `422` — Meta's rule does not soften because
  you addressed a number instead of a thread.

  In practice, first contact is always a [template](/reseller/channels/whatsapp/templates).
</Warning>

## Media

Inbound photos, documents and voice notes arrive as a reference:

```json theme={null}
"media": { "ref": "9f2c…", "mime": "image/jpeg", "transcription": "" }
```

```http theme={null}
GET /api/v1/partner/tenants/{ref}/media/{ref}/
```

Returns the bytes with their content type. Store them, show them — a photo of
damage, a signed PO, a voice note.

<Note>
  `ref` is **our** id for the message, never Meta's `media_id`. Meta's only
  resolves with the merchant's own access token, so handing one to you would be
  both useless and a leak of the merchant's Meta object graph. That is also why
  this streams the file rather than redirecting: there is no URL to redirect to
  that would not need the merchant's token.
</Note>

Meta expires media after about 30 days. After that this is a `502` and the file
is honestly gone — fetch anything you intend to keep when the event arrives.

## Read receipts and typing

```http theme={null}
POST /api/v1/partner/tenants/{ref}/conversations/{id}/read/
POST /api/v1/partner/tenants/{ref}/conversations/{id}/typing/
```

Both default to the conversation's most recent inbound message; pass
`{"message_id": "wamid.…"}` to name one.

<Warning>
  **These are calls, not settings, and that is the point.** Blue ticks should fire
  when a person opens the thread. A receipt sent automatically on arrival tells a
  customer that somebody read their message when nobody has looked at it — and a
  customer who works that out stops believing the ticks at all.
</Warning>

Meta only accepts a typing indicator on the same request that marks a message
read, so `typing/` marks it read too. That is their shape, not ours. The
indicator clears when your next message is sent, or after about 25 seconds, and
cannot be cancelled — send it when somebody is actually composing.

## Taking over from the assistant

```http theme={null}
POST /api/v1/partner/tenants/{ref}/conversations/{id}/handoff/
```

```json theme={null}
{ "on": true, "reason": "agent_took_over" }
```

While a human holds a conversation the assistant stays silent and stops
nudging. Send `{"on": false}` to give it back.

Handover is a judgement somebody made, so only somebody releases it. The one
exception is credit exhaustion: a conversation paused because the merchant ran
out of credits resumes on its own when they top up, provided no human has
replied in the meantime.

<Note>
  The merchant's own staff can do this from their phone. Replying from the
  WhatsApp Business App takes over automatically, and `/human`, `/ai` and
  `/clear` pause, release and wipe a thread. Every one of those reaches you as
  `handoff.requested` — so your inbox stays correct even when nobody used your UI.
</Note>

## Asking the assistant to answer

```http theme={null}
POST /api/v1/partner/tenants/{ref}/conversations/{id}/turns/
```

```json theme={null}
{ "text": "optional message to inject first", "force": false }
```

**202**, and the turn runs asynchronously — the reply arrives as `message.sent`.
Useful for a "draft a reply" button, or to re-run a turn that failed on a
provider outage.

Refused with **409 `human_control`** while a human holds the conversation,
unless you pass `force`. Without that, your own takeover would be silently
undone by your own next call.

## What a conversation looks like

```json theme={null}
{
  "id": "…",
  "object": "conversation",
  "status": "waiting_reply",
  "channel": "whatsapp",
  "agent": "…",
  "turns": 6,
  "contact": { "name": "Priya", "phone": "+919876543210", "lead_id": "…" },
  "human_control": { "on": false, "reason": "", "since": "" },
  "window": { "open": true, "last_inbound_at": "…", "expires_at": "…" },
  "whatsapp_number": "106540352242922",
  "created_at": "…", "updated_at": "…"
}
```

`whatsapp_number` is the number this thread is happening on. Replies go out from
it automatically — answering from a merchant's other number would arrive, to the
customer, as a message from a stranger.
