# List inbound messages

`GET /v1/inbox/inbound-messages`

A polling feed of new inbound emails, each with its conversation's state.

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

Poll it to act on replies as they arrive. Messages are ordered by when DialEcho stored them (`created_at`, then `id`), not by the provider's `received_at`, so a message that syncs late still comes through. Every inbound kind is included, auto-replies too: check `kind`. Marking a conversation read or archived does not take its messages out of the feed; archived threads still appear, with `conversation_state.archived` set.

`next_cursor` is always set, even on an empty page. While `has_more` is `true`, ask for the next page right away. When it is `false` the current window is walked: save `next_cursor` and poll with it again in about 10 seconds, with jitter. Store each page's messages before you save its cursor, so a crash only repeats a page.

Delivery is at least once. Each new window starts 5 minutes before the previous one ended (never before `since`), so recent messages come again, and a repeated page repeats its messages: deduplicate by message `id`, never by sender, subject or time.

The feed follows timestamps; it is not a change log, so a message stored late (a slow import, or access granted later) can land behind your position. Run a second, slower walk at least daily with its own cursor, starting from your original `since` (or `1970-01-01T00:00:00Z` for all history), and deduplicate ids across both walks.

Only messages of conversations the key's member can see are returned. Each request looks at up to 1,000 messages, so a page can be empty while `has_more` is `true`.

## 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`. |
| `since` | `timestamp` | No | Where the feed starts: messages stored at or after this time. Only for the first request; after that the cursor carries it. ISO 8601; a time without an offset is read as UTC (URL-encode `+` as `%2B`). Default: when the API key was created. |
| `limit` | `integer` | No | Messages per page. 1 to 100. Default: `50`. |
| `cursor` | `string` | No | The `next_cursor` you saved, unchanged. It carries `campaign_id`, `domain` and `since`: send it alone, or with exactly the same filters. Up to 4,096 characters. |

## Response

**Status:** `200 OK`

| Name | Type | Description |
| --- | --- | --- |
| `data` | `array of objects` | Inbound messages, in the order DialEcho stored them. |
| `data.message` | `object` | The inbound message. |
| `data.message.id` | `UUID` | The message. Pass an inbound message's `id` as `in_reply_to_message_id` to answer it. |
| `data.message.conversation_id` | `UUID` | Its conversation. |
| `data.message.campaign_id` | `UUID` | The conversation's campaign. |
| `data.message.domain` | `string` | The conversation's inbox. One of `contact`, `realestate`. |
| `data.message.direction` | `string` | `inbound` from the contact, `outbound` to them. One of `inbound`, `outbound`. |
| `data.message.kind` | `string \| null` | `human_reply`: a person wrote back. `auto_reply`: an auto-responder, such as an out-of-office. `manual_reply`: sent by your team or through this API. `ai_reply`: sent by the AI email agent. `null` when the message was never classified, as with older messages and `origin_message`. One of `human_reply`, `auto_reply`, `manual_reply`, `ai_reply`. |
| `data.message.from_email` | `string \| null` | The sender, following the member's email visibility. |
| `data.message.to_emails` | `array of strings` | The recipients. Addresses the member's role hides are left out. |
| `data.message.cc_emails` | `array of strings` | The CC recipients, the same way. |
| `data.message.subject` | `string \| null` | The subject. |
| `data.message.text` | `string \| null` | The plain-text body. `Restricted` when the member's role masks messages. |
| `data.message.html` | `string \| null` | The HTML body exactly as stored, or `null` (also when the role masks messages). Sanitize it before you display it and never execute it. |
| `data.message.attachments` | `array of objects` | What was attached; there are no download links. Empty when the role masks messages. A detail that is not known is `null`. |
| `data.message.attachments.filename` | `string \| null` | The file name. |
| `data.message.attachments.content_type` | `string \| null` | The MIME type, for example `application/pdf`. |
| `data.message.attachments.size_bytes` | `integer \| null` | The size in bytes. |
| `data.message.created_at` | `timestamp \| null` | When DialEcho stored the message (RFC 3339, UTC). The inbound feed is ordered by it. |
| `data.message.received_at` | `timestamp \| null` | When the mail provider received an inbound message. |
| `data.message.sent_at` | `timestamp \| null` | When an outbound message was sent. |
| `data.message.is_synthetic` | `boolean` | `true` only for a conversation's `origin_message`, which is not a stored message. |
| `data.message.external_reply` | `object \| null` | Set on replies sent through this API; `null` on every other message. |
| `data.message.external_reply.api_key_id` | `UUID \| null` | The API key that sent it. |
| `data.message.external_reply.organization_member_id` | `UUID \| null` | The key's team member. |
| `data.message.external_reply.automated` | `boolean` | Always `true`: sent by an integration, not typed by a person. |
| `data.message.external_reply.in_reply_to_message_id` | `UUID \| null` | The inbound message it answered. |
| `data.conversation_state` | `object` | Its conversation as it is now. Read the whole conversation with `GET /v1/inbox/conversations/{conversation_id}`. |
| `data.conversation_state.status` | `string` | The conversation status. One of `open`, `needs_review`, `booked`, `not_interested`, `closed`, `suppressed`. |
| `data.conversation_state.archived` | `boolean` | `true` while it is archived. |
| `data.conversation_state.read` | `boolean` | `true` when it was read at or after its latest inbound message. |
| `next_cursor` | `string` | Always set: pass it back as `cursor` for the next page or the next poll. |
| `has_more` | `boolean` | `true` while the current window holds more messages: ask again right away. `false`: poll again later. |
| `scan_through` | `timestamp` | The end of the current window (RFC 3339, UTC). |

## Example request

**curl**

```bash
curl "https://development.agencydevworks.ai/v1/inbox/inbound-messages?campaign_id=cccccccc-0000-4000-8000-000000000001&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/inbound-messages?campaign_id=cccccccc-0000-4000-8000-000000000001&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": [
    {
      "message": {
        "id": "66666666-0000-4000-8000-000000000002",
        "conversation_id": "44444444-0000-4000-8000-000000000001",
        "campaign_id": "cccccccc-0000-4000-8000-000000000001",
        "domain": "contact",
        "direction": "inbound",
        "kind": "human_reply",
        "from_email": "pat@example.com",
        "to_emails": ["jordan@outreach.example.com"],
        "cc_emails": [],
        "subject": "Re: Your solar quote",
        "text": "Is Thursday afternoon available?",
        "html": "<p>Is Thursday afternoon available?</p>",
        "attachments": [{ "filename": "electric-bill.pdf", "content_type": "application/pdf", "size_bytes": 48213 }],
        "created_at": "2026-11-02T16:20:02Z",
        "received_at": "2026-11-02T16:20:00Z",
        "sent_at": null,
        "is_synthetic": false,
        "external_reply": null
      },
      "conversation_state": { "status": "open", "archived": false, "read": false }
    }
  ],
  "next_cursor": "v2.exampleOpaqueCursor",
  "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, `since` your last checkpoint, and restart the daily walk too. |
| 422 | `invalid_cursor` | The cursor was not issued to this key for this feed, 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`, `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

- `POST /v1/inbox/conversations/{conversation_id}/replies` · [Reply to a conversation](/api/inbox/reply)
- `GET /v1/inbox/conversations/{conversation_id}` · [Retrieve a conversation](/api/inbox/get-conversation)
- `PATCH /v1/inbox/conversations/{conversation_id}` · [Update a conversation](/api/inbox/update-conversation)
