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": "…", "subject": "Invoice 4021 overdue", "highlights": { "subject": ["Invoice 4021 <mark>overdue</mark>"], "body": ["…your balance is now <mark>overdue</mark>…"] } }
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}), the conversation response, or a webhook payload — list and search rows 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.