# List conversations

`GET /v1/inbox/conversations`

Returns the conversations that changed in a time window, oldest change first.

**Scope:** `inbox:read`

**Access:** The key's member must be able to use the campaign's email inbox in DialEcho (its message and email permissions) and see the contact; other conversations are left out.

A change scan, not the inbox's newest-first view: conversations are ordered by `updated_at` (then `id`), oldest first, and `updated_at` moves on every new message and every read or archive change. The first page fixes the end of the window, returned as `scan_through`; a conversation that changes after it waits for the next scan.

To keep a copy in sync, scan with `updated_since` and follow `next_cursor` until it is `null`. Start the next scan with `updated_since` set to the last `scan_through` minus 5 minutes, and keep the newest copy of each conversation by `id`. Deleted conversations are never listed: if one you have stored answers `404` later, treat it as deleted.

Only conversations the key's member can see are returned. Each request looks at up to 1,000 conversations, so a page can hold fewer than `limit`, or none, while `next_cursor` is still set. Lists carry no totals.

## Query parameters

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_id` | `UUID` | No | Only this campaign. It must be a campaign whose inbox the key can read, otherwise `404`. |
| `domain` | `string` | No | Only this inbox. Leave it out for both. One of `contact`, `realestate`. |
| `status` | `string` | No | Only conversations with this status. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. |
| `archived` | `string` | No | `false` lists conversations that are not archived, `true` only archived ones, `all` both. Default: `false`. One of `false`, `true`, `all`. |
| `updated_since` | `timestamp` | No | Only conversations whose `updated_at` is at or after this time. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). Leave it out to scan from the first conversation. |
| `limit` | `integer` | No | Conversations per page. 1 to 100. Default: `50`. |
| `cursor` | `string` | No | The `next_cursor` of the previous page, unchanged. It carries the filters: send it alone, or with exactly the filters of the first page. Up to 4,096 characters. |

## Response

**Status:** `200 OK`

| Name | Type | Description |
| --- | --- | --- |
| `data` | `array of objects` | Conversations, oldest change first. |
| `data.id` | `UUID` | The conversation. |
| `data.campaign_id` | `UUID` | The conversation's campaign. |
| `data.domain` | `string` | Which inbox it is in: a contact campaign's (`contact`) or a real-estate offer campaign's (`realestate`). One of `contact`, `realestate`. |
| `data.contact_id` | `UUID \| null` | The CRM contact, or `null` when it is not linked to one. |
| `data.subject` | `string \| null` | The thread subject. |
| `data.participant_email` | `string \| null` | The other party's address, following the member's email visibility. |
| `data.status` | `string` | The conversation status shown in the inbox. Archiving sets `closed` and restoring sets `open`; a `suppressed` conversation stays `suppressed`. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. |
| `data.ai_status` | `string` | Where the AI email agent is with the thread, independent of `status`. Archiving sets `disabled`. One of `pending`, `queued`, `drafted`, `sent`, `auto_replied`, `rejected`, `needs_review`, `disabled`. |
| `data.updated_at` | `timestamp` | When the conversation last changed, read and archive changes included (RFC 3339, UTC). |
| `data.last_message_at` | `timestamp \| null` | Time of its latest message. |
| `data.last_inbound_at` | `timestamp \| null` | Time of its latest inbound message. |
| `data.read` | `boolean` | `true` when it was read at or after its latest inbound message. A conversation without inbound messages is read. |
| `data.archived` | `boolean` | `true` while it is archived. |
| `data.reply_identities` | `object` | The two senders a reply can use (its `send_as`), as they are now. A preview, not a reservation: the sender is checked again when you reply. |
| `data.reply_identities.campaign_mailbox` | `object` | The campaign mailbox the thread came from: the mailbox or account that sent the email the contact answered. |
| `data.reply_identities.campaign_mailbox.email` | `string \| null` | The sender address; `null` when none is set up. |
| `data.reply_identities.campaign_mailbox.available` | `boolean` | Whether a reply can be sent from it now. |
| `data.reply_identities.campaign_mailbox.reason` | `string` | Only present when `available` is `false`: `not_configured` (no usable sender is set up) or `reconnect_required` (the account or mailbox must be reconnected in DialEcho). One of `not_configured`, `reconnect_required`. |
| `data.reply_identities.reply_address` | `object` | The campaign's connected reply address, set in the campaign's email settings. On a real-estate conversation it is available only when the thread is already on that connected account. |
| `data.reply_identities.reply_address.email` | `string \| null` | The sender address; `null` when none is set up. |
| `data.reply_identities.reply_address.available` | `boolean` | Whether a reply can be sent from it now. |
| `data.reply_identities.reply_address.reason` | `string` | Only present when `available` is `false`: `not_configured` (no usable sender is set up) or `reconnect_required` (the account or mailbox must be reconnected in DialEcho). One of `not_configured`, `reconnect_required`. |
| `next_cursor` | `string \| null` | Pass it back as `cursor` for the next page; `null` when the window is walked. |
| `has_more` | `boolean` | `true` while `next_cursor` is set. |
| `scan_through` | `timestamp` | The end of this scan's window (RFC 3339, UTC). Start the next scan from it, minus 5 minutes. |

## Example request

**curl**

```bash
curl "https://development.agencydevworks.ai/v1/inbox/conversations?campaign_id=cccccccc-0000-4000-8000-000000000001&updated_since=2026-11-02T00:00:00Z&limit=50" \
  -H "Authorization: Bearer dei_your_key"
```

**JavaScript**

```js
const response = await fetch("https://development.agencydevworks.ai/v1/inbox/conversations?campaign_id=cccccccc-0000-4000-8000-000000000001&updated_since=2026-11-02T00:00:00Z&limit=50", {
  headers: {
    "Authorization": "Bearer dei_your_key",
  },
});
const data = await response.json();
```

## Example response

**Status:** `200 OK`

```json
{
  "data": [
    {
      "id": "44444444-0000-4000-8000-000000000001",
      "campaign_id": "cccccccc-0000-4000-8000-000000000001",
      "domain": "contact",
      "contact_id": "bbbbbbbb-0000-4000-8000-000000000001",
      "subject": "Your solar quote",
      "participant_email": "pat@example.com",
      "status": "open",
      "ai_status": "needs_review",
      "updated_at": "2026-11-02T16:20:02Z",
      "last_message_at": "2026-11-02T16:20:00Z",
      "last_inbound_at": "2026-11-02T16:20:00Z",
      "read": false,
      "archived": false,
      "reply_identities": {
        "campaign_mailbox": { "email": "jordan@outreach.example.com", "available": true },
        "reply_address": { "email": "jordan@example.com", "available": true }
      }
    }
  ],
  "next_cursor": null,
  "has_more": false,
  "scan_through": "2026-11-02T16:21:00Z"
}
```

## Errors

| HTTP | Code | When |
| --- | --- | --- |
| 403 | `insufficient_scope` | The key does not have `inbox:read`. |
| 404 | `not_found` | `campaign_id` is not a campaign whose inbox the key can read, or `domain` names an inbox this DialEcho deployment does not run. |
| 409 | `resync_required` | The cursor expired (after 30 days) or the key's or its member's access changed since it was issued. Start again without a cursor. |
| 422 | `invalid_cursor` | The cursor was not issued to this key for this list, was changed, or was sent with different filters. |
| 422 | `invalid_request` | A parameter is invalid. `error.fields` names the first invalid filter (`campaign_id`, `domain`, `status`, `archived`, `updated_since`), or `query.limit` or `query.cursor`. |
| 503 | `dependency_unavailable` | The inbox or the access check is temporarily unavailable. Retry with backoff. |

Every endpoint can also answer `401 invalid_api_key`, `429 rate_limited` and `503 dependency_unavailable`. See [Errors](/api/#errors) in the overview.

## Related endpoints

- `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation)
- `GET /v1/inbox/inbound-messages` · [List inbound messages](/api/inbox/list-inbound)
- `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation)
