Search, Threads & Conversations
Once Primitive stores an inbound email, you can list it, search it with structured filters and full-text matching, and read it in the context of the wider conversation it belongs to. This page covers the read surface: listing the inbox, the search filter set, facets and snippets, conversations versus threads, and the awaitReply long-poll that blocks until someone replies.
For the write side — sending, replying, and forwarding — see Sending Mail. For inbound delivery, webhooks, and replays, see Receiving Mail.
Listing the Inbox
GET /v1/emails returns stored inbound mail newest-first with cursor pagination.
curl "https://api.primitive.dev/v1/emails?limit=50" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
Supported query parameters:
| Param | Description |
|---|---|
limit | Page size, 1–100 (default 50). |
cursor | Continuation cursor from a previous response's meta.cursor. |
domain_id | Restrict to one verified domain. |
status | One of pending, accepted, completed, rejected. |
search | Free-text substring match across sender, recipient, and subject. |
date_from, date_to | Inclusive ISO 8601 bounds on created_at. |
since | Forward-tail cursor: return only emails strictly newer than this cursor, oldest-first. Mutually exclusive with cursor. |
wait | Long-poll hold in seconds, 0–30 (default 0). Holds the request open until a newer email arrives or the timeout elapses. Requires since. |
The response carries data (the page of emails) and meta (total, limit, and the next cursor, which is null when you reach the end):
{ "success": true, "data": [ { "id": "…", "sender": "alice@example.com", "subject": "Invoice", "status": "completed" } ], "meta": { "total": 128, "limit": 50, "cursor": "2026-06-25T18:04:11.123456Z|<id>" } }
The list endpoint's search is a simple substring match. For relevance ranking, body text, attachment filters, or date operators, use search.
Tailing the inbox
To watch for new mail without missing or double-reading messages, use since + wait rather than re-scanning by date. Pass the last cursor you saw as since and a wait of up to 30 seconds; the request returns as soon as a newer email arrives (oldest-first), or empty when the hold times out. Carry the new cursor into the next call:
curl "https://api.primitive.dev/v1/emails?since=2026-06-25T18:04:11.123456Z%7C<id>&wait=25" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
since keys on visibility order, so this loop is lossless — prefer it over polling search?date_from=... for live tailing.
Searching
GET /v1/emails/search is the structured query endpoint. It accepts dedicated filter parameters, a full-text query (q), or both. Each parameter accepts at most one value — repeating a parameter is a validation error.
curl "https://api.primitive.dev/v1/emails/search?q=invoice&from=billing@acme.com&has_attachment=true&sort=received_at_desc" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
Filter Parameters
| Param | Type | Description |
|---|---|---|
q | string (≤500) | Full-text query matched across subject, body, sender, and recipient. Supports the search DSL. |
from | string (≤255) | Sender address (when it contains @) or sender domain. |
to | string (≤255) | Recipient address or domain. |
subject | string (≤500) | Full-text match against the subject. |
body | string (≤2000) | Full-text match against the body. |
domain_id | uuid | Restrict to one verified inbound domain. |
reply_to_sent_email_id | uuid | Only inbound emails that are replies to a specific sent email. |
status | enum | pending, accepted, completed, or rejected. |
date_from, date_to | ISO 8601 | Inclusive bounds on received_at. |
has_attachment | true / false | Whether the email has attachments. |
spam_score_lt | number | Spam score strictly below this value. |
spam_score_gte | number | Spam score at or above this value. |
sort | enum | relevance, received_at_desc, or received_at_asc. |
cursor | string (≤200) | Continuation cursor from meta.cursor. |
limit | number | Page size, 1–100 (default 50). |
snippet | true / false | Include highlighted snippets (default true). |
include_facets | true / false | Include aggregated facet counts (default true). |
sort defaults to relevance when the query carries text-bearing terms (q, subject, or body), and to received_at_desc otherwise. Requesting sort=relevance without any text term is a validation error. The cursor's sort mode must match the sort you request on the follow-up page.
To scan a fixed window of matching mail oldest-first, sort received_at_asc and bound it with date_from:
curl "https://api.primitive.dev/v1/emails/search?sort=received_at_asc&date_from=2026-06-26T00:00:00Z" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
For live tailing of newly-arrived mail, prefer the list endpoint's lossless since + wait long-poll (see Tailing the inbox) rather than repeatedly advancing date_from.
Search DSL
The q parameter accepts plain terms, "quoted phrases", and field:value filters. A query can hold up to 32 terms. Fields combine with the structured parameters above — use whichever is more convenient.
| Field | Example | Meaning |
|---|---|---|
from: | from:alice@example.com / from:example.com | Sender address (with @) or sender domain. |
to: | to:support@yourdomain.com | Recipient address or domain. |
subject: | subject:"order confirmed" | Full-text subject match. |
body: | body:refund | Full-text body match. |
domain: | domain:yourdomain.com | Receiving domain. |
status: | status:completed | pending, accepted, completed, or rejected. |
has: | has:attachment | Only attachment is accepted. |
before: | before:2026-06-01 | received_at at or before the date (or ISO 8601 timestamp). |
after: | after:2026-05-01 | received_at at or after the date. |
A combined example — invoices from a domain, with an attachment, in a date window, that mention "overdue":
curl -G "https://api.primitive.dev/v1/emails/search" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ --data-urlencode 'q=overdue from:acme.com has:attachment after:2026-05-01 before:2026-06-01' \ --data-urlencode 'sort=relevance'
If a text query reduces to nothing but stop-words, the API returns a validation error asking you to add a non-stop-word term or fall back to structured filters.
Snippets
When snippet=true (the default) and the query has text-bearing terms, each result carries a highlights object with matched fragments for subject and body:
{ "id": "7d8f2c1e-3b4a-4f6d-9e21-5c0a7b8d9e10", "message_id": "<invoice-4021@billing.example.com>", "domain_id": "0c9e8d7f-6a5b-4c3d-8e2f-1a0b9c8d7e6f", "org_id": "4b3a2c1d-0e9f-4a8b-9c7d-6e5f4a3b2c1d", "status": "completed", "sender": "billing@example.com", "recipient": "agent@acme.primitive.email", "subject": "Invoice 4021 overdue", "domain": "acme.primitive.email", "spam_score": 0.4, "created_at": "2026-09-20T14:03:11.482Z", "received_at": "2026-09-20T14:03:11.391Z", "raw_size_bytes": 18233, "attachment_count": 1, "webhook_status": "fired", "webhook_attempt_count": 1, "from_known_address": true, "reply_count": 0, "last_replied_at": null, "awaiting": "you", "automated": false, "automated_reasons": [], "score": 0.61, "highlights": { "subject": ["Invoice 4021 <mark>overdue</mark>"], "body": ["…your balance is now <mark>overdue</mark>…"] } }
score is present only when results are sorted by relevance.
Snippets are omitted for pure structured queries (no q, subject, or body), since there is nothing to highlight.
Facets
When include_facets=true (the default), the response adds a facets block aggregating the full matching set — the top senders and domains (up to 20 buckets each), plus status and attachment counts:
{ "facets": { "by_sender": [{ "value": "alice@example.com", "count": 12 }], "by_domain": [{ "value": "example.com", "count": 18 }], "by_status": [{ "value": "completed", "count": 30 }], "has_attachment": { "true": 7, "false": 23 } } }
Result Cap and Limits
Search counts and pagination are capped at 10,000 matching emails. When the matching set is larger, meta.total reports 10000 and meta.total_capped is true — narrow the query (tighter date range or more filters) to reach the rows beyond the cap. The page itself still paginates normally via meta.cursor.
{ "data": [ /* … */ ], "meta": { "total": 10000, "total_capped": true, "limit": 50, "cursor": "…", "sort": "received_at_desc" } }
A query that exceeds the server's time budget returns 504 search_timeout. Narrow the date range or add filters and retry.
Conversations
A conversation is the full back-and-forth — every inbound and outbound message — that an email belongs to, returned as ordered, chat-model-ready turns with bodies inline.
GET /v1/emails/{id}/conversation takes the id of any inbound email in the conversation:
curl "https://api.primitive.dev/v1/emails/<inbound-email-id>/conversation" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
Messages come back oldest-first. Each turn has a direction (inbound or outbound) and a derived role (inbound → user, outbound → assistant), so the array drops straight into a chat model:
{ "success": true, "data": { "thread_id": "…", "subject": "Order confirmed", "message_count": 3, "truncated": false, "messages": [ { "role": "user", "direction": "inbound", "id": "…", "from": "alice@example.com", "subject": "Order confirmed", "text": "Where is my order?" }, { "role": "assistant", "direction": "outbound", "id": "…", "from": "support@yourdomain.com", "subject": "Re: Order confirmed", "text": "It ships today." } ] } }
For a brand-new message with no thread yet, the response contains just that single turn. Conversations are capped at 200 messages; when the cap is reached, truncated is true and message_count reflects the full recorded count.
Threads
A thread is the lightweight index of the same exchange. GET /v1/threads/{id} returns thread metadata plus all inbound and outbound messages interleaved oldest-first — but without bodies. Each entry carries a direction and an id; fetch the body on demand with GET /v1/emails/{id} for inbound or GET /v1/sent-emails/{id} for outbound.
curl "https://api.primitive.dev/v1/threads/<thread-id>" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
Discover a thread_id from the thread_id field on an email or sent-email detail record (GET /v1/emails/{id}, GET /v1/sent-emails/{id}), a sent-email list row (GET /v1/sent-emails), an inbound list row (GET /v1/emails), the conversation response, or a webhook payload. Search rows (GET /v1/emails/search) do not include it. Threads are also capped at 200 messages; compare message_count against messages.length to detect truncation.
Conversation or thread? Use the conversation endpoint when you want the whole exchange with bodies in one call — for example to hand it to a model. Use the thread endpoint when you only need the message index and will fetch individual bodies selectively, which keeps the response small for long exchanges.
Waiting for a Reply
GET /v1/sent-emails/{id}/reply (the awaitReply operation) answers the question "did they reply yet?" for a message you sent. Pass the id of the sent email, and Primitive returns the threaded reply if one has arrived, matched on the reply's threading rather than a from/subject guess.
By default it returns immediately — with the reply, or reply: null if nothing has arrived:
curl "https://api.primitive.dev/v1/sent-emails/<sent-email-id>/reply" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
The response also carries sent_email_id, waited, and timed_out — use timed_out to tell "no reply yet" apart from "waited and gave up." With wait=true it long-polls, holding the request open until a reply lands or wait_timeout_ms elapses (1000–30000 ms, default 10000). This makes synchronous agent-to-agent chat a single call:
curl "https://api.primitive.dev/v1/sent-emails/<sent-email-id>/reply?wait=true&wait_timeout_ms=30000" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY"
awaitReply vs. Send Wait Mode
These two waits look similar but block on different events — don't confuse them:
Send Wait Mode (wait: true on /v1/send-mail) | awaitReply (wait=true on /v1/sent-emails/{id}/reply) | |
|---|---|---|
| Blocks until | the outbound delivery outcome is known (the receiving side's SMTP response, or timeout) | a reply to your message arrives on the thread |
| Answers | "was my message delivered?" | "did they write back?" |
| Returns | delivery status (delivered, bounced, deferred, wait_timeout) | the threaded reply email, or reply: null on timeout |
Send Wait Mode tells you the message reached the recipient's mail server. awaitReply tells you the recipient (or their agent) actually responded. A typical agent-to-agent exchange uses both: send with Wait Mode to confirm delivery, then awaitReply with wait=true to collect the response.
Related Pages
- Sending Mail: outbound send, replies, and Wait Mode.
- Receiving Mail: inbound delivery, webhooks, and stored email access.
- Webhook Payload: inbound event schema.
- REST API: full endpoint list and envelope shape.
- SDKs: high-level helpers for each language.
- CLI: terminal workflows.