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:

ParamDescription
limitPage size, 1100 (default 50).
cursorContinuation cursor from a previous response's meta.cursor.
domain_idRestrict to one verified domain.
statusOne of pending, accepted, completed, rejected.
searchFree-text substring match across sender, recipient, and subject.
date_from, date_toInclusive ISO 8601 bounds on created_at.
sinceForward-tail cursor: return only emails strictly newer than this cursor, oldest-first. Mutually exclusive with cursor.
waitLong-poll hold in seconds, 030 (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

ParamTypeDescription
qstring (≤500)Full-text query matched across subject, body, sender, and recipient. Supports the search DSL.
fromstring (≤255)Sender address (when it contains @) or sender domain.
tostring (≤255)Recipient address or domain.
subjectstring (≤500)Full-text match against the subject.
bodystring (≤2000)Full-text match against the body.
domain_iduuidRestrict to one verified inbound domain.
reply_to_sent_email_iduuidOnly inbound emails that are replies to a specific sent email.
statusenumpending, accepted, completed, or rejected.
date_from, date_toISO 8601Inclusive bounds on received_at.
has_attachmenttrue / falseWhether the email has attachments.
spam_score_ltnumberSpam score strictly below this value.
spam_score_gtenumberSpam score at or above this value.
sortenumrelevance, received_at_desc, or received_at_asc.
cursorstring (≤200)Continuation cursor from meta.cursor.
limitnumberPage size, 1100 (default 50).
snippettrue / falseInclude highlighted snippets (default true).
include_facetstrue / falseInclude 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.

FieldExampleMeaning
from:from:alice@example.com / from:example.comSender address (with @) or sender domain.
to:to:support@yourdomain.comRecipient address or domain.
subject:subject:"order confirmed"Full-text subject match.
body:body:refundFull-text body match.
domain:domain:yourdomain.comReceiving domain.
status:status:completedpending, accepted, completed, or rejected.
has:has:attachmentOnly attachment is accepted.
before:before:2026-06-01received_at at or before the date (or ISO 8601 timestamp).
after:after:2026-05-01received_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 untilthe 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?"
Returnsdelivery 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.