# Messages

List conversations, read their messages, reply with text, media, buttons and quick replies, start new DMs, and hand a thread to another app.

Direct messages live in **conversations**. Each conversation belongs to one connected account and holds the messages between that account and one person (or a group). This page covers reading and answering them. For the overall architecture, start with the [Inbox overview](https://pozy.ai/docs/inbox).

## List conversations

```bash cURL
curl "https://api.pozy.ai/v1/inbox/conversations?profileId=6650c1e2a3b4c5d6e7f80911&status=active&limit=50" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
let cursor: string | undefined;
do {
  const query = new URLSearchParams({ profileId: "6650c1e2a3b4c5d6e7f80911", status: "active", limit: "50" });
  if (cursor) query.set("cursor", cursor);
  const res = await fetch(`https://api.pozy.ai/v1/inbox/conversations?${query}`, {
    headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error);
  for (const c of data.data) console.log(c.platform, c.participantName, c.lastMessage);
  cursor = data.pagination.hasMore ? data.pagination.nextCursor ?? undefined : undefined;
} while (cursor);
```
```python Python
import os
import requests

params = {"profileId": "6650c1e2a3b4c5d6e7f80911", "status": "active", "limit": 50}
while True:
    resp = requests.get(
        "https://api.pozy.ai/v1/inbox/conversations",
        headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
        params=params,
        timeout=30,
    )
    body = resp.json()
    for c in body["data"]:
        print(c["platform"], c["participantName"], c["lastMessage"])
    if not body["pagination"]["hasMore"]:
        break
    params["cursor"] = body["pagination"]["nextCursor"]
```

| Parameter | What it does |
| --- | --- |
| `profileId`, `accountId`, `platform` | Narrow to a customer, one account or one platform |
| `status` | `active` or `archived` |
| `folder` | `inbox` (default) or `requests` for message requests, where the platform separates them |
| `tags` | Conversations whose contact carries these tags |
| `sortOrder` | `desc` (default, newest activity first) or `asc` |
| `limit`, `cursor` | Up to 100 per page. Pass `pagination.nextCursor` back as `cursor` |

Each conversation carries the participant (`participantName`, `participantPicture`, `participantVerifiedType`), `lastMessage`, `updatedTime`, `unreadCount` and `threadControl`. Conversations that started from an ad carry `adAttribution` with the ad, ad set and campaign names. Instagram conversations can carry `instagramProfile` (`isFollower`, `isFollowing`, `followerCount`, `isVerified`); treat a missing value as unknown, never as "not a follower".

To find a conversation by what was said, use [`GET /v1/inbox/conversations/search`](https://pozy.ai/docs/api/searchInboxConversations) with `query` (up to 200 characters) and an optional `direction`.

## Read messages

`GET /v1/inbox/conversations/{conversationId}/messages` needs the `accountId` and returns messages oldest first by default (`sortOrder=desc` flips it). Pages hold up to 100 messages.

```json
{
  "id": "6650c1e2a3b4c5d6e7f81201",
  "conversationId": "6650c1e2a3b4c5d6e7f80b33",
  "platform": "instagram",
  "direction": "incoming",
  "message": "Hi, is the blue one still in stock?",
  "senderName": "Jane",
  "createdAt": "2026-10-29T14:00:03.000Z",
  "attachments": [],
  "deliveryStatus": null,
  "reactions": [],
  "isEdited": false,
  "isDeleted": false,
  "sentVia": null
}
```

- `direction` is `incoming` or `outgoing`.
- `deliveryStatus` on outgoing messages moves through `sent`, `delivered`, `read`, `played` (voice notes) or `failed`, where the platform reports it. `deliveryError` explains a failure.
- `attachments[]` have a `type` (`image`, `video`, `audio`, `file`, `sticker`, `share`, `template`), a `url` and, for links that expire, a `refreshUrl`. [`GET .../attachments/{index}`](https://pozy.ai/docs/api/getMessageAttachment) always resolves a fresh link (a redirect by default, or JSON with `format=json`).
- Edited and unsent messages keep their history in `editHistory`, `isDeleted` and `deletedAt`.
- `sentVia` names what sent an outgoing message: `human`, `api`, `broadcast`, `sequence`, `workflow`, `comment_automation` or `bulk-api`. `null` means unknown, for example a message sent from the platform's own app.

## Reply

`POST /v1/inbox/conversations/{conversationId}/messages` sends a message into an existing conversation. `accountId` is required; everything else depends on what you send.

```bash cURL
curl -X POST https://api.pozy.ai/v1/inbox/conversations/6650c1e2a3b4c5d6e7f80b33/messages \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9b6c0f4e-1f0b-4b38-9a1e-6f2e8d7f1c55" \
  -d '{
    "accountId": "6650c1e2a3b4c5d6e7f80a22",
    "message": "Yes, the blue one is in stock. Want me to hold it?",
    "quickReplies": [
      { "title": "Hold it", "payload": "HOLD_BLUE" },
      { "title": "No thanks", "payload": "NO_THANKS" }
    ]
  }'
```
```ts Node
import { randomUUID } from "node:crypto";

const res = await fetch("https://api.pozy.ai/v1/inbox/conversations/6650c1e2a3b4c5d6e7f80b33/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Idempotency-Key": randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    accountId: "6650c1e2a3b4c5d6e7f80a22",
    message: "Yes, the blue one is in stock. Want me to hold it?",
    quickReplies: [
      { title: "Hold it", payload: "HOLD_BLUE" },
      { title: "No thanks", payload: "NO_THANKS" },
    ],
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data);
```
```python Python
import uuid

resp = requests.post(
    "https://api.pozy.ai/v1/inbox/conversations/6650c1e2a3b4c5d6e7f80b33/messages",
    headers={
        "Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "accountId": "6650c1e2a3b4c5d6e7f80a22",
        "message": "Yes, the blue one is in stock. Want me to hold it?",
        "quickReplies": [{"title": "Hold it", "payload": "HOLD_BLUE"}, {"title": "No thanks", "payload": "NO_THANKS"}],
    },
    timeout=30,
)
print(resp.json())
```

A `200` means Pozy accepted the message, not that it was delivered. Delivery arrives later as `message.sent`, then `message.delivered` and `message.read` (or `message.failed`) on platforms that report them.

### What you can send

| Field | What it sends | Where |
| --- | --- | --- |
| `message` | Text | Everywhere |
| `attachmentUrl` + `attachmentType` (`image`, `video`, `audio`, `file`) | One attachment from a public HTTPS URL. `attachmentName` sets the file name | Most DM platforms, not Bluesky or Reddit |
| `quickReplies` | Up to 13 tappable chips (title up to 20 characters) | Instagram, Facebook |
| `buttons` | 1 to 3 `url`, `postback` or `phone` buttons under the text. Text is then capped at 640 characters. `phone` is Facebook only | Instagram, Facebook |
| `template` | A `generic` card carousel, up to 10 cards with title, subtitle, image and buttons | Instagram, Facebook |
| `interactive` | Lists, call-to-action URLs, flows, product messages, location and contact requests | WhatsApp |
| `replyTo` | The platform message id to quote. On Slack it threads the reply instead | WhatsApp, Telegram, Slack |
| `replyMarkup` | Inline or reply keyboards | Telegram |
| `voiceNote` | Send an OGG/Opus audio attachment as a voice note | WhatsApp |
| `location`, `contacts` | A location pin, or contact cards | WhatsApp (location also on iMessage) |
| `sharedMediaId`, `sticker` | Share one of the account's own posts, or send the heart sticker, on their own | Instagram |
| `subject`, `effect`, `contactCard` | A bold title line, a screen effect, or your contact card | iMessage |
| `channel` | Force one send over `sms`, `rcs` or `whatsapp` instead of letting the number pick | iMessage numbers with those add-ons |
| `cc` | Extra recipients on the reply | Email threads from connected Google or Microsoft mailboxes |

A postback button with a payload of `pozy:workflow:<workflowId>` starts that workflow when tapped. See [Workflows](https://pozy.ai/docs/automations/workflows).

Upload a local file first with [`POST /v1/media/upload-direct`](https://pozy.ai/docs/api/uploadMediaDirect) (multipart) and send the returned `url` as `attachmentUrl`.

### Messaging windows

Platforms limit when a business can write to a person:

| Platform | Rule |
| --- | --- |
| Instagram, Facebook | Free-form replies within 24 hours of the person's last message. Up to 7 days with `messagingType: "MESSAGE_TAG"` and `messageTag: "HUMAN_AGENT"`, for replies typed by a person only |
| WhatsApp | Free-form messages within the 24-hour customer service window. Outside it, send an approved template. See [WhatsApp](https://pozy.ai/docs/whatsapp/templates) |
| TikTok | Reply only. Up to 10 messages within 48 hours of the person's last message |
| X, Bluesky, Telegram | No window, but the person must accept DMs from you |

`HUMAN_AGENT` is refused on automated sends (workflows, sequences, broadcasts). The other message tags were retired by Meta and fail.

## Start a conversation

`POST /v1/inbox/conversations` opens a new conversation, or appends to the existing one with that person. Name the recipient with `participantId` or `participantUsername`.

- **X:** Pozy checks that the person accepts DMs from your account first and answers `422` with code `DM_NOT_ALLOWED` when they do not. Pass `skipDmCheck: true` if you already verified it. X DMs are billed per call ([pricing](https://pozy.ai/docs/billing#x-is-billed-per-call)).
- **WhatsApp:** a first message must be a template: pass `templateName`, `templateLanguage`, `templateParams` and, where the template has them, `headerMedia` and `templateButtonParams`.
- **TikTok:** businesses cannot start conversations.

## Manage conversations

| Task | Call |
| --- | --- |
| Archive or unarchive | `PUT /v1/inbox/conversations/{id}` with `status: "archived"` or `"active"` |
| Mark as read | `POST /v1/inbox/conversations/{id}/read` |
| Show "typing..." | `POST /v1/inbox/conversations/{id}/typing` |
| React to a message | `POST /v1/inbox/conversations/{id}/messages/{messageId}/reactions` with an `emoji`; `DELETE` to remove |
| Edit a sent message | `PATCH /v1/inbox/conversations/{id}/messages/{messageId}` (Telegram text and keyboards) |
| Delete a message | `DELETE /v1/inbox/conversations/{id}/messages/{messageId}` where the platform allows unsending |
| Label a conversation | Create Page labels with `POST /v1/inbox/labels`, then attach them with `POST .../labels` (Facebook) |
| Delete a conversation | `DELETE /v1/inbox/conversations/{id}` |

### Message requests

On Facebook, DMs from people the Page has not accepted wait in a requests folder. List them with `folder=requests` and accept one with [`POST .../accept`](https://pozy.ai/docs/api/acceptConversationRequest), which sends your first `message` and moves the thread into the inbox. Instagram does not expose the folder (the list answers `400` with `PLATFORM_LIMITATION`), but accepting still works, and requests arrive as `message.received` once the person writes.

## Hand a conversation to another app

On WhatsApp, Facebook and Instagram, another app can own a thread, for example Meta's own inbox app or a business agent. While it does:

- Incoming messages still arrive as `message.received`, flagged `metadata.standby: true`. Pozy runs no automation on them.
- Your sends answer `409` with code `not_thread_owner`.

Move the thread with [`POST /v1/inbox/conversations/{id}/thread-control`](https://pozy.ai/docs/api/setConversationThreadControl):

| `action` | Effect |
| --- | --- |
| `pass` | Give the thread to `targetAppId` (or `target: "ai_agent"`), with an optional `metadata` note |
| `take` | Take it back. Meta allows this only to the Page's primary receiver |
| `request` | Ask the current owner to pass it. Nothing changes until they do |
| `release` | Return it to the primary receiver |

`conversation.control_changed` fires whenever the owner changes. On WhatsApp, sending any message takes control back, so a bot that answers every `message.received` must skip standby messages.

## When a send fails

| Response | Meaning | What to do |
| --- | --- | --- |
| `400` `PLATFORM_LIMITATION` | The platform cannot do what you asked, for example an attachment on Bluesky | Send without it |
| `409` `not_thread_owner` | Another app owns the thread | Take the thread back first |
| `422` `DM_NOT_ALLOWED` | X recipient does not accept your DMs | Do not retry |
| `500` | The platform failed the send. It may or may not have gone out | List the conversation's messages before retrying |

Send an `Idempotency-Key` with every reply. If the response is lost after a successful send, a retry with the same key returns the original response instead of a second message. A failed (non-2xx) request releases the key, so after an ambiguous `500`, check the messages first; an empty result is inconclusive, not proof it was not sent. See [Idempotency](https://pozy.ai/docs/getting-started/idempotency).

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/inbox/conversations`](https://pozy.ai/docs/api/listInboxConversations) | List conversations |
| [`POST /v1/inbox/conversations`](https://pozy.ai/docs/api/createInboxConversation) | Create conversation |
| [`GET /v1/inbox/conversations/search`](https://pozy.ai/docs/api/searchInboxConversations) | Search conversations |
| [`GET /v1/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/getInboxConversation) | Get conversation |
| [`PUT /v1/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/updateInboxConversation) | Update conversation status |
| [`DELETE /v1/inbox/conversations/{conversationId}`](https://pozy.ai/docs/api/deleteInboxConversation) | Delete conversation |
| [`GET /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/getInboxConversationMessages) | List messages |
| [`POST /v1/inbox/conversations/{conversationId}/messages`](https://pozy.ai/docs/api/sendInboxMessage) | Send message |
| [`PATCH /v1/inbox/conversations/{conversationId}/messages/{messageId}`](https://pozy.ai/docs/api/editInboxMessage) | Edit message |
| [`DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId}`](https://pozy.ai/docs/api/deleteInboxMessage) | Delete message |
| [`POST /v1/inbox/conversations/{conversationId}/typing`](https://pozy.ai/docs/api/sendTypingIndicator) | Send typing indicator |
| [`POST /v1/inbox/conversations/{conversationId}/thread-control`](https://pozy.ai/docs/api/setConversationThreadControl) | Change who answers a conversation (handover) |
| [`POST /v1/inbox/conversations/{conversationId}/accept`](https://pozy.ai/docs/api/acceptConversationRequest) | Accept a message request |
| [`GET /v1/inbox/labels`](https://pozy.ai/docs/api/listInboxLabels) | List the Page's conversation labels |
| [`POST /v1/inbox/labels`](https://pozy.ai/docs/api/createInboxLabel) | Create a conversation label |
| [`DELETE /v1/inbox/labels/{labelId}`](https://pozy.ai/docs/api/deleteInboxLabel) | Delete a conversation label |
| [`GET /v1/inbox/conversations/{conversationId}/labels`](https://pozy.ai/docs/api/listConversationLabels) | List the labels on a conversation |
| [`POST /v1/inbox/conversations/{conversationId}/labels`](https://pozy.ai/docs/api/addConversationLabel) | Add a label to a conversation |
| [`DELETE /v1/inbox/conversations/{conversationId}/labels/{labelId}`](https://pozy.ai/docs/api/removeConversationLabel) | Remove a label from a conversation |
| [`POST /v1/inbox/conversations/{conversationId}/read`](https://pozy.ai/docs/api/markConversationRead) | Mark a conversation as read |
| [`POST /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions`](https://pozy.ai/docs/api/addMessageReaction) | Add reaction |
| [`DELETE /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions`](https://pozy.ai/docs/api/removeMessageReaction) | Remove reaction |
| [`POST /v1/media/upload-direct`](https://pozy.ai/docs/api/uploadMediaDirect) | Upload media file |
| [`GET /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}`](https://pozy.ai/docs/api/getMessageAttachment) | Resolve message attachment |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`conversation.control_changed`](https://pozy.ai/docs/webhooks/events#conversation-control-changed) | Conversation control changed. |
| [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. |
| [`message.sent`](https://pozy.ai/docs/webhooks/events#message-sent) | Message sent. |
| [`message.edited`](https://pozy.ai/docs/webhooks/events#message-edited) | Message edited. |
| [`message.deleted`](https://pozy.ai/docs/webhooks/events#message-deleted) | Message deleted. |
| [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. |
| [`message.read`](https://pozy.ai/docs/webhooks/events#message-read) | Message read. |
| [`message.played`](https://pozy.ai/docs/webhooks/events#message-played) | Message played. |
| [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. |
| [`reaction.received`](https://pozy.ai/docs/webhooks/events#reaction-received) | Reaction received. |
| [`referral.received`](https://pozy.ai/docs/webhooks/events#referral-received) | Referral received. |
