List inbound messages
GET /
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. | object | The inbound message. |
data. | UUID | The message. Pass an inbound message's id as in_reply_to_message_id to answer it. |
data. | UUID | Its conversation. |
data. | UUID | The conversation's campaign. |
data. | string | The conversation's inbox. One of contact, realestate. |
data. | string | inbound from the contact, outbound to them. One of inbound, outbound. |
data. | 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. | string | null | The sender, following the member's email visibility. |
data. | array of strings | The recipients. Addresses the member's role hides are left out. |
data. | array of strings | The CC recipients, the same way. |
data. | string | null | The subject. |
data. | string | null | The plain-text body. Restricted when the member's role masks messages. |
data. | 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. | 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. | string | null | The file name. |
data. | string | null | The MIME type, for example application/pdf. |
data. | integer | null | The size in bytes. |
data. | timestamp | null | When DialEcho stored the message (RFC 3339, UTC). The inbound feed is ordered by it. |
data. | timestamp | null | When the mail provider received an inbound message. |
data. | timestamp | null | When an outbound message was sent. |
data. | boolean | true only for a conversation's origin_message, which is not a stored message. |
data. | object | null | Set on replies sent through this API; null on every other message. |
data. | UUID | null | The API key that sent it. |
data. | UUID | null | The key's team member. |
data. | boolean | Always true: sent by an integration, not typed by a person. |
data. | UUID | null | The inbound message it answered. |
data. | object | Its conversation as it is now. Read the whole conversation with GET /v1/inbox/conversations/{conversation_id}. |
data. | string | The conversation status. One of open, needs_review, booked, not_interested, closed, suppressed. |
data. | boolean | true while it is archived. |
data. | 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 in the overview.
Related endpoints
- POST
/· Reply to a conversationv1/ inbox/ conversations/ {conversation_id}/ replies - GET
/· Retrieve a conversationv1/ inbox/ conversations/ {conversation_id} - PATCH
/· Update a conversationv1/ inbox/ conversations/ {conversation_id}