---
title: "Search, Threads & Conversations"
description: "Filter stored inbound mail with the search DSL, read the full back-and-forth as conversations or threads, and wait for a reply to arrive."
canonical: "https://docs.primitive.dev/docs/search"
last-updated: "2026-05-17T00:00:00.000Z"
---

# 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](/docs/sending). For inbound delivery, webhooks, and replays, see [Receiving Mail](/docs/receiving).

## 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`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.`recipient`One delivered mailbox, matched exactly and case-insensitively, applied before pagination. Your access never causes a refusal: a mailbox your credential cannot read lists nothing, and one it reads only in part (for example through an email rule) lists only that part. For a credential limited to some mailboxes, and a mailbox that is neither one of its addresses nor on one of its domains, `meta.recipient_outside_access: true` says your credential reads nothing there, and `meta.recipient_rules_only: true` says it reads only what email rules give it there. An address on one of your domains gets no note, though it may still list nothing.`date_from`, `date_to`Inclusive ISO 8601 bounds on `created_at`, with `Z` or a UTC offset (`2026-10-02T00:00:00Z`, `2026-10-02T00:00:00-04:00`). A time with no zone is rejected.`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 list holds only mail your credential can read. An empty or short page says what matched among that mail, not what a mailbox you cannot fully read holds.

The response carries `data` (the page of emails) and `meta` (`total`, `limit`, and the next `cursor`, which is `null` when you reach the end).

`meta.total` counts the whole matching set, up to 10,000. When the request has `search` or `since`, it counts only what this page saw instead: it is exact whenever no further match exists, and `meta.total_capped` is `true` only when the API saw a match beyond this page, meaning at least that many match and more follow via `meta.cursor`. A page that holds exactly the last `limit` matches is full but reports `total_capped: false`:


```
{
  "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`q`string (≤500)Full-text query matched across subject, body, sender, and recipient. Supports the [search DSL](#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`uuidRestrict to one verified inbound domain.`reply_to_sent_email_id`uuidOnly inbound emails that are replies to a specific sent email.`status`enum`pending`, `accepted`, `completed`, or `rejected`.`date_from`, `date_to`ISO 8601Inclusive bounds on `received_at`, with `Z` or a UTC offset (`2026-10-02T00:00:00-04:00`). A time with no zone is rejected.`has_attachment``true` / `false`Whether the email has attachments.`spam_score_lt`numberSpam score strictly below this value.`spam_score_gte`numberSpam score at or above this value.`awaiting``you` / `them`Whose turn it is in the conversation. Matches delivered mail only: `rejected` emails are never returned by it.`automated``true` / `false`Whether the email is classified as automated (bounces, auto-replies, mailing lists, reports), decided at ingest from its envelope and captured headers. A best-effort signal, not proof of who sent it.`sort`enum`relevance`, `received_at_desc`, or `received_at_asc`.`cursor`string (≤200)Continuation cursor from `meta.cursor`.`limit`numberPage size, `1`–`100` (default `50`).`snippet``true` / `false`Include the `highlights` object on each result (default `true`). The `snippet` body preview on each result is returned either way.`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](#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, so use whichever is more convenient. A `q` made only of field filters, such as `q=automated:false awaiting:you`, searches no text: it behaves exactly like the equivalent structured parameters, sorts by `received_at_desc` by default, and returns no highlights. Text you do type must leave at least one searchable word after stop-word removal, so `q=the` is rejected with a `validation_error`, with or without field filters beside it.

FieldExampleMeaning`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.`awaiting:``awaiting:you``you` or `them`: whose turn it is in the conversation (see the `awaiting` field). Matches delivered mail only: an email whose `status` is `rejected` never matches either value.`automated:``automated:false``true` or `false`: whether the email is classified as automated from its envelope and captured headers (see the `automated` field and its `automated_reasons`).

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.

#### Alternatives with OR

Every term in `q` must match. To accept any one of several, put an upper-case `OR` between them:


```
curl -G "https://api.primitive.dev/v1/emails/search" \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY" \
  --data-urlencode 'q=invoice OR receipt OR "past due" from:acme.com'
```


- `OR` joins text terms only: words, `"quoted phrases"`, and `subject:` / `body:` terms. It cannot join filters such as `from:` or `has:`; run one search per value instead.

- `OR` binds tighter than the implicit AND, so `acme invoice OR receipt` means `acme` and either `invoice` or `receipt`. There are no parentheses.

- It must be upper case. Lower-case `or` is an ordinary word, and a stop word. To search for the word itself, quote it: `"OR"`.

- Each alternative counts toward the 32-term limit, and must contain a searchable word: `the OR invoice` is rejected.

There is no `NOT`: a search cannot exclude a term, and an upper-case `NOT` in `q` is rejected rather than ignored. With `prefix=true`, a trailing `OR` or `NOT` is read as a word still being typed.

A `q` the search cannot honour is always a `validation_error` whose message says what is wrong and what is accepted: an operator used where it is not supported, an unknown `field:` (quote text that contains a colon, such as a URL), an unterminated quote, or text that is all stop-words.

### 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 most recent 1,000 matches, with 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 }
  }
}
```


Each of `by_sender`, `by_domain` and `by_status` is ordered by `count`, highest first. Values with the same count are ordered by `value` ascending, compared as the bytes of its UTF-8 encoding (so upper case sorts before lower case), and a `null` value comes last. `by_sender` and `by_domain` keep the first 20 values in that order, so when more values tie at the cutoff than there are slots, the same request over the same mail always lists the same ones.

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

Email list search (`search` on `GET /v1/emails`) reads at most a fixed number of messages per page within a time budget, and a page that spends either before it fills returns `504 search_timeout`.

Sent mail search (`q` on `GET /v1/sent-emails`) is bounded the same way: each page reads at most a fixed number of sends within a time budget, and a page that spends either before it fills returns `504 search_timeout`. A `since` tail held open with `wait` is the exception: if the search runs out of time while the request is held, the response is the empty page, and the next poll resumes from the same cursor.

For a text search (`q`, `subject` or `body`), the page is always returned once it has been read. If the count or the facets then run out of time, the response is still `200` with the page (which may itself be partial, see below): `meta.total` is `null` with `meta.total_unavailable: true`, and the `facets` block is omitted with `meta.facets_unavailable: true`. When a search matches more than 1,000 emails, its facets cover the most recent 1,000 matches and `meta.facets_capped` is `true` (read the counts as "1,000+"). Each of these flags appears only when it is `true`.

### Partial pages

A text search whose filters reject most of what the text matches, such as a one-letter `prefix=true` search with a `from` that matches little or nothing, can stop a page at its time bound before the page fills. The response is then `200` with `meta.partial: true`: `data` holds fewer than `limit` results, possibly none, and `meta.cursor` continues the search after the last email examined. Every result returned is correct and in order, and following `meta.cursor` until it is `null` reads exactly the results a single unbounded search would have returned.

So a page shorter than `limit` does not mean the results are exhausted. Only a `null` `meta.cursor` does.


```
{
  "data": [],
  "meta": { "total": null, "total_unavailable": true, "total_capped": false, "limit": 50, "cursor": "…", "sort": "received_at_desc", "partial": true }
}
```


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

### Jumping to a Message

The cap keeps only the newest 200 messages. To open an older message inside its conversation, for example a search result, pass `around` with its id. The response holds up to `limit` messages before it (default `25`, at most `100`), the message itself, and up to `limit` after it, oldest-first, with the same fields and the same access rules as the full read:


```
curl "https://api.primitive.dev/v1/emails/<email-id>/conversation?around=<message-id>&limit=25" \
  -H "Authorization: Bearer $PRIMITIVE_API_KEY"
```


`around` takes an inbound email id or a sent email id from the same conversation, and on these reads the path id may be a sent email id too, so an outbound search result opens directly. A message that is not in this conversation, or that your key cannot read, returns `404 not_found`.

The response adds `before_cursor`, `after_cursor`, `has_more_before` and `has_more_after`. To scroll, pass `before=<before_cursor>` for older messages or `after=<after_cursor>` for newer ones; each page returns up to `limit` messages and fresh cursors. Pass cursors back unchanged; their format may change. On these reads `truncated` is `true` when there are more messages on either side. Send only one of `around`, `before`, `after` and `since` per request; a malformed value or a combination returns `400 validation_error`.

## 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 a threaded reply if one has arrived.

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 verified reply lands or `wait_timeout_ms` elapses (1000 to 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"
```


### Verifying who replied

Threading (`In-Reply-To`) routes a message to your send, but it is not proof of authorship: every recipient of your message sees the same Message-ID, and anyone who learns it can reuse it. Each reply therefore carries:

FieldMeaning`sender_verified``true` when the reply's From address is one of the send's `to`, `cc` or `bcc` recipients and it either passed DMARC within that address's domain with the whole message body signed by a passing, aligned DKIM signature, or was delivered directly by Primitive. A DMARC pass that rests on SPF alone, or on a DKIM signature that covers only part of the body, is `false`.`sender_matches_recipient``true` when the From address is one of the send's recipients, whether or not it was authenticated. On its own this is not proof of authorship.`auth`The SPF, DKIM and DMARC verdicts for the reply, in the same shape as `auth` on an email.

Check `sender_verified` before acting on instructions in a reply. When it is `false`, treat the reply as untrusted input.

When several messages thread onto the same send, they are ranked rather than taken in arrival order. A verified reply is preferred over an unverified one, and an ordinary reply over an automated one (`Auto-Submitted`, such as an out-of-office). Bounces, complaints and reports are never returned. With `wait=true`, the call keeps waiting while the best reply so far is unverified, and returns it with `sender_verified: false` only if nothing verified arrives before `wait_timeout_ms`. So when replies may come from senders that cannot be verified, budget for the full timeout. Because replies are ranked, a later call can return a different reply than an earlier one.

### awaitReply vs. Send Wait Mode

These two waits look similar but block on different events — don't confuse them:

Send [Wait Mode](/docs/sending#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 threadAnswers"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](/docs/sending): outbound send, replies, and Wait Mode.

- [Receiving Mail](/docs/receiving): inbound delivery, webhooks, and stored email access.

- [Webhook Payload](/docs/webhook-payload): inbound event schema.

- [REST API](/docs/api): full endpoint list and envelope shape.

- [SDKs](/docs/sdks): high-level helpers for each language.

- [CLI](/docs/cli): terminal workflows.

Maintained by the [primitive.dev team](/about) · [Trust & security](/trust)Last updated: May 17, 2026
