---
title: "Long-poll what changed"
method: GET
path: "/changes"
tags: ["Changes"]
---

# Long-poll what changed

`GET /changes`

## Request

- Base URL: `https://api.primitive.dev/v1`
- URL: `https://api.primitive.dev/v1/changes`
- Auth: HTTP bearer

One feed of small invalidations (ids and kinds) for everything an app keeps a snapshot of: inbound mail, sent mail, the member's conversation read state, the agent directory and presence, and the credential's own access. Non-consumptive and replayable: any number of devices can read the same cursor independently.

Start with `since=start` (or no `since`): it returns an empty page whose `next_cursor` is a baseline. Take it BEFORE the initial list reads; every change that commits after it is delivered, including ones those reads already reflect. Then call with `since=<next_cursor>&wait=20` in a loop. Changes come oldest first in commit order, at least once: a write that commits late is never skipped, because positions are assigned at commit.

Every change is scoped to what this credential's full reads would show: a connected agent sees only its own mail, its own sends and sends it provably received; mail held for sender authentication is announced only when released; read state reaches only the member it belongs to; agent changes reach members and agents allowed to view the network; organization API keys receive no agent changes. Function credentials cannot call this.

Changes are kept 7 days. A cursor answers 410 `cursor_expired` only when changes after it were pruned (an idle cursor stays valid); rebuild the snapshots from `since=start`.

## Query parameters

- `since` string
- `wait` integer
- `limit` integer
- `expand` 'message'
- `address` string

## Response `200`

A page of changes

- object
  - `success` true, required
  - `data` ChangePage, required
    - `changes` Change[], required: Oldest first, in commit order.
      - `kind` 'email.visible' | 'email.updated' | 'email.deleted' | 'sent_email.created' | 'sent_email.updated' | 'sent_email.deleted' | 'thread.read_state_changed' | 'thread.mute_changed' | 'agent.changed' | 'access.changed', required: `email.visible`: an inbound email became visible to this credential (arrived, or its sender-authentication hold was released). `email.updated`: something a list row or conversation shows changed. `email.deleted`: gone, or withdrawn from view. `sent_email.created` / `sent_email.updated`: a send appeared or changed; `status` is its current status, and several changes between two reads collapse into one entry. Created is a hint (first committed after the cursor): treat both as upsert, including `sent_email.updated` for an id you have not seen. `sent_email.deleted`: the send was deleted. `thread.read_state_changed`: the signed-in member's read cursor for `thread_id` moved, on any device. `thread.mute_changed`: `thread_id` was muted or unmuted for an address this credential reads, which changes the `muted` flag and `exclude_muted` results of its mail. `agent.changed`: an agent in the directory changed (connected, disconnected, renamed, hidden, listed, presence extended, receiver live). `access.changed`: this credential's own access changed; rebuild every snapshot from `since=start`.
      - `email_id` string, uuid, nullable, required: email.* kinds.
      - `sent_email_id` string, uuid, nullable, required: sent_email.* kinds.
      - `thread_id` string, uuid, nullable, required: The conversation, when known. Null for a connected agent reading a send, or its deletion, that it received but did not send, matching what GET /sent-emails withholds. Inbound mail is announced when it becomes visible, which can be before its conversation is resolved, so `email.visible` can carry null; resolving or changing the conversation is always announced afterwards as `email.updated` with the `thread_id`.
      - `status` string, nullable, required: sent_email.created and sent_email.updated: the send's current status.
      - `agent_address` string, nullable, required: agent.changed: the agent's address.
      - `reason` 'membership' | 'agent_connection' | 'null', nullable, required: access.changed: `membership` (the member's role or membership) or `agent_connection` (this agent connection's status, owner or generation).
      - `changed_at` string, date-time, required: The commit position in microsecond UTC form. Orders the feed; not a business timestamp.
      - `message` union: A message inlined by `expand=message`. `direction` `inbound` is a GET /emails row; `outbound` is a GET /sent-emails row.
        - ChangeInboundMessage: An inbound message inlined by `expand=message`, as GET /emails returns it.
          - `id` string, uuid, required
          - `message_id` string, nullable
          - `domain_id` string, uuid, nullable
          - `org_id` string, uuid, nullable
          - `status` 'pending' | 'accepted' | 'completed' | 'rejected', required: Lifecycle status of an INBOUND email (a row in the `emails` table). Distinct from `SentEmailStatus`, which describes the OUTBOUND lifecycle (the `sent_emails` table) and uses a different vocabulary because the lifecycles differ. Possible values: - `pending`: the row was inserted at ingestion (mx_main) and has not yet completed the spam / filter / auth pipeline. Body and parsed fields are present; webhook delivery is not yet scheduled. Most rows transition out of `pending` within seconds. - `accepted`: the inbound passed the policy gates and is queued for webhook delivery. The `webhook_status` field tracks the separate webhook-delivery lifecycle from this point. - `completed`: terminal success. Webhook delivery attempted and acknowledged by every active endpoint, OR no endpoints are configured, so the row is durably archived. - `rejected`: terminal failure at ingestion (spam, blocked sender, filter rule, malformed). The body and metadata are stored for auditing but no webhook fires and the row is not repliable. See also `webhook_status` (separate enum tracking the webhook-delivery state machine) and `SentEmailStatus` (the outbound vocabulary).
          - `sender` string, required: SMTP envelope sender (return-path) the inbound mail server accepted. For most legitimate mail this equals the bare address in the From header; for mailing lists, bounce handlers, and forwarders it is typically the bounce address rather than the human-visible sender. For the parsed From-header value (with display name handling and a sender-fallback when the header is unparseable), GET the email by id and use `from_email`.
          - `recipient` string, required
          - `subject` string, nullable
          - `domain` string, required
          - `spam_score` number, nullable
          - `created_at` string, date-time, required
          - `received_at` string, date-time, required
          - `raw_size_bytes` integer, nullable
          - `webhook_status` 'pending' | 'in_flight' | 'fired' | 'failed' | 'exhausted' | 'null', nullable: Webhook-delivery state for an inbound email. Tracks a SEPARATE lifecycle from the email's `status` field; the same row carries both. Possible values: - `pending`: ingestion is past `pending` (the email itself is `accepted`) but the webhook fan-out has not yet started for this row. - `in_flight`: at least one delivery attempt is in flight. - `fired`: terminal success. Every active endpoint acknowledged the delivery (or accepted it after retries). - `failed`: terminal partial-failure. At least one endpoint exhausted its retry budget; some endpoints may still have succeeded. - `exhausted`: terminal failure. Every endpoint exhausted its retry budget without success. - `null`: no endpoints configured, so no webhook lifecycle applies. Note that the value `pending` here does NOT mean the email is `pending`; it means the email is past ingestion but webhook delivery has not yet begun. Two overlapping uses of the word `pending` for distinct lifecycle phases.
          - `webhook_attempt_count` integer, required
          - `thread_id` string, uuid, nullable: Conversation thread this message belongs to. Fetch `/threads/{thread_id}` for the full ordered thread. NULL on messages received before threading was enabled.
          - `presence_control` PresenceControl, nullable
            - `status` 'verified' | 'pending' | 'rejected', required
            - `valid_for_ms` integer, required
          - `automation_headers` object, nullable: What the message declared about being automated, verbatim: `List-Unsubscribe` (RFC 2369/8058), `List-Id` (RFC 2919), `Precedence`, and `Auto-Submitted` (RFC 3834). Null or absent when the message declared none, and on messages received before these headers were captured, so a null value is not evidence that a person sent the message.
            - `list_unsubscribe` string
            - `list_id` string
            - `precedence` string
            - `auto_submitted` string
          - `reply_count` integer, required: Number of replies recorded against this email: sends whose `in_reply_to_email_id` is this email, the same linkage that populates `EmailDetail.replies`, excluding sends that never went out (status `gate_denied`, `agent_failed`, `canceled`). Queued and scheduled replies count. Under an agent connection, only the replies that agent can see in `EmailDetail.replies`. Unlike `awaiting`, this counts replies to this one email, not to the thread.
          - `last_replied_at` string, date-time, nullable, required: `created_at` of the latest reply counted in `reply_count`, or null when `reply_count` is 0.
          - `awaiting` 'you' | 'them', required: Whose turn it is in this email's conversation. A send counts as a reply unless its status is `gate_denied`, `agent_failed`, `canceled` (queued and scheduled replies count: they are committed to go). For an email with a `thread_id`: `them` when the thread's latest counted outbound send (by send `created_at`, including any counted reply to this email itself) is at or after the thread's latest inbound message (by `received_at`); otherwise `you`. For an email with no `thread_id`: `them` when it has at least one counted reply (`reply_count > 0` for an API key), otherwise `you`. It is a thread-level fact: every email in a thread carries the same value, and it reflects sends made by any credential in the organization, including other agent connections. It changes without a new inbound message, for example when a reply is sent, a queued reply fails, a scheduled reply is canceled, or a message is deleted, and is always current when read.
          - `automated` boolean, required: Whether the message was sent by a machine rather than a person. An inbound email is `automated` when any of these holds, decided once when it arrives: `null_envelope_sender` (an empty MAIL FROM, a bounce); `no_identifiable_sender` (no address in the envelope or From); `own_address` (a sender address is exactly one of the addresses the mail was delivered to; sharing a domain with the recipient is not enough, so a colleague or another agent in the organization is not automated); `mailer_daemon` (mailer-daemon@ or postmaster@ on any domain); `auto_submitted` (Auto-Submitted with any keyword but `no`, an empty one included); `precedence` (Precedence bulk, list, junk or auto_reply); `list_unsubscribe` (List-Unsubscribe present); `list_id` (List-Id present); `auto_response_suppress` (X-Auto-Response-Suppress asks for no automatic reply: All, OOF or AutoReply); `failed_recipients` (X-Failed-Recipients present, a bounce); `report` (a delivery, feedback or disposition report). Mail Primitive's own automatic responders decline to answer is always `automated`. The header rules only see headers captured at ingest, so older mail may lack them. This is loop and noise protection, not sender authentication.
          - `automated_reasons` string[], required: Why `automated` is true, in rule order; empty when it is false. Current values: `null_envelope_sender`, `no_identifiable_sender`, `own_address`, `mailer_daemon`, `auto_submitted`, `precedence`, `list_unsubscribe`, `list_id`, `auto_response_suppress`, `failed_recipients`, `report`. Treat an unfamiliar value as a reason added after your client was built.
          - `repeat` object, nullable: Set when Primitive sent this message as part of a repeating send. Resolved from Primitive's own records, never from the message content. Null on every other message and on servers that predate repeating sends.
            - `repeat_id` string, uuid, required
            - `sequence` integer, required
          - `sender_member` object, nullable
            - `address` string, email, required
            - `user_id` string, required
            - `name` string, nullable, required
          - `fyi` boolean: True when the email is an informational signal (an acknowledgement, read, working or typing signal) and nothing else, so it needs no answer, not even another informational reply. Never true for mail from the reader's owner. Older servers omit it.
          - `interaction_hint` string: How to place this email in a timeline without downloading it: `status`, `card`, `none` or `pending`. Not declared as a closed enum so that a value added later does not fail decoding. Decided once by the server from the message's authoritative `interaction.json` part, and only when a passing DKIM signature covering From and Content-Type authenticates the From that wrote it; the `X-Primitive-Interaction` header is never used, so an unsigned or forged message is always `none`. `status`: a pure status signal (`read`, `working`, `typing`, or an `ack` in the strict signal shape), shown as a property of the message it is about rather than as a row. `card`: an interaction that renders as its own timeline item (for example `ack-request`, `x402.payment`, `wake.dispatch`, `primitive.contact`, `repeat.tick`, `repeat.stop`). `none`: ordinary mail, including any interaction kind not listed. `pending`: the email has not finished processing; read it again later. Email processed before the field existed is `none`. Treat an unfamiliar value as `none`. Older servers omit it.
          - `interaction_kind` string, nullable: The `<protocol>/<version>` that `interaction_hint` was decided from (for example `repeat.tick/1` or `read/1`) when `interaction_hint` is `status` or `card`; null otherwise.
          - `interaction_candidate` boolean: UNVERIFIED early signal for the `pending` window: true when the raw message carries an `X-Primitive-Interaction` header or a MIME part named `interaction.json`, recorded before any signature is checked. Use it only to decide, while `interaction_hint` is `pending`, whether to show a placeholder instead of the raw text. It is never evidence of an interaction: anyone can set that header or name a part that way, so it must not grant trust or select an interaction kind. Ignore it once `interaction_hint` is anything other than `pending`.
          - `relay` object, nullable: Set when the message reached Primitive through a Primitive mail relay. Null for other mail. The field may be absent; treat a missing value the same as null.
            - `hostname` string, required: The relay hostname the domain's MX record points at.
            - `via` string, required: How the message arrived. Currently always `mail_relay`. Treat an unfamiliar value as one added after your client was built.
          - `from_verified` boolean, nullable, required: True when the From header address, `from_header_email`, passed DMARC for its domain on arrival (aligned SPF or DKIM); decided once, at receipt. It is about the From header, never the envelope: `sender`, the `from` parameter and `from:` term, and the change feed's `address` match the envelope sender, which can differ. It proves the domain, not the mailbox. False with no From header address, when authentication failed or was not attempted (a delivery between Primitive mailboxes counts only when it passed DMARC too), and for mail stored before the verdict was recorded. Null on sent rows in search results. Not a spam or safety judgement.
          - `from_header_email` string, nullable, required: The From header address, lowercased, that `from_verified` is about; null when the message had none. Unlike `sender` it is never the envelope address.
          - `from_name` string, nullable: Display name from the `From:` header, decoded for display: RFC 2047 encoded words are decoded in their declared charset and folded lines are joined (e.g. `Dana Rúiz` for `=?UTF-8?Q?Dana_R=C3=BAiz?= <dana@example.com>`). Null when the header carries no display name. For display only: identify the sender by `from_email`, never by this name, which the sender chooses freely.
          - `in_reply_to` string[], nullable: Message-IDs from the `In-Reply-To` header, as `parsed.in_reply_to` on GET /emails/{id}. Lets a client attach a reply to its conversation from the list alone, including while `thread_id` is still null because the message has not finished processing. Null when the header was absent, and wherever `snippet` is null because the content is withheld: content not yet available, a failed parse, mail pending sender authentication, or a sender-blocked rejection.
          - `references` string[], nullable: Message-IDs from the `References` header, oldest first, as `parsed.references` on GET /emails/{id}, shortened to at most 20 entries on the list: a longer chain keeps its first entry (the thread root) and the 19 most recent. Read GET /emails/{id} for the full chain. Null when the header was absent, and wherever `snippet` is null because the content is withheld: content not yet available, a failed parse, mail pending sender authentication, or a sender-blocked rejection.
          - `parse_status` 'pending' | 'processing' | 'complete' | 'failed' | 'null', nullable: Parsing state on inbox, history, arrival and detail reads. Failed is terminal and differs from valid empty content (complete). Null covers legacy rows. Optional on shared summaries for compatibility with older payloads and other summary endpoints.
          - `delivery_report` object, nullable: Classified inbound delivery notification, or null. The linked sent_email_id is resolved within the receiving organization; null means uncorrelated. This is a reported outcome, not a rewrite of the original send status. Transient reports may still be retried. Optional for older payloads and other summary endpoints.
            - `type` 'permanent' | 'transient' | 'undetermined', required
            - `sent_email_id` string, nullable, required
            - `failed_recipients` string[], required
            - `status_code` string, nullable, required
            - `diagnostic` string, nullable, required
          - `body_text` string, nullable: With `include=body_text`: the plain-text body, cut as `truncated_fields` says.
          - `body_html` string, nullable: With `include=body_html`: the HTML body, cut as `truncated_fields` says.
          - `headers` IncludedMessageHeaders: Stored addressing and threading headers. A member is null when absent or withheld.
            - `from` string, nullable, required
            - `to` string[], nullable, required
            - `cc` string[], nullable, required
            - `bcc` string[], nullable, required
            - `reply_to` string[], nullable, required
            - `in_reply_to` string[], nullable, required
            - `references` string[], nullable, required
            - `automation` object, nullable, required: Received mail only.
          - `attachments` object[]: With `include=attachments`: attachment metadata as the detail read reports it. Never the bytes.
          - `truncated` boolean: With `include`: true when a body on this row was cut or left out.
          - `truncated_fields` string[]: With `include`: the body fields on this row that were cut or left out.
          - `muted` boolean: True when the reader's address muted this email's thread with `PUT /threads/{id}/mute`. For a connected agent the reader is its own address; for other credentials it is the address the email was delivered to.
          - `snippet` string, nullable, required: A plain-text preview of `body_text` (never `body_html`), computed once when the message is stored: quoted replies (an "On ... wrote:" line, an "Original Message" separator, a From/Sent header block, `>` lines) and a `-- ` signature are cut off, whitespace is collapsed to single spaces, and the result is at most 200 characters. A message with no text body, or with nothing left once quotes and the signature are cut (a quote-only reply), has no snippet. It is not a summary and may end mid-word; read the full body from the detail endpoint. Null wherever the detail endpoint would not show the body: no text body, content not yet available or discarded, a failed parse, or a sender-blocked rejection.
          - `direction` 'inbound', required
        - ChangeOutboundMessage: List-row projection of a sent-email record. Drops `body_text` and `body_html` to keep paginated responses small; fetch /sent-emails/{id} for the full record with bodies.
          - `id` string, uuid, required
          - `status` 'queued' | 'submitted_to_agent' | 'agent_failed' | 'gate_denied' | 'unknown' | 'delivered' | 'bounced' | 'deferred' | 'wait_timeout' | 'scheduled' | 'canceled', required: Lifecycle status of a sent_emails row. Possible values: - `queued`: pre-call INSERT; the outbound agent has not yet replied. - `submitted_to_agent`: agent accepted; `queue_id` is set. - `agent_failed`: agent rejected; `error_code` and `error_message` carry the reason. - `gate_denied`: a recipient-scope gate denied the send; the agent was never called. The `gates` array carries the denial detail. /send-mail returns 403 in this case so callers see the denial synchronously; /sent-emails additionally records the row for historical lookup, which is when this status appears in a listing. - `unknown`: terminal indeterminate; the on-box log poller couldn't classify the receiver's response. - `delivered` / `bounced` / `deferred` / `wait_timeout`: terminal delivery outcomes (see DeliveryStatus). - `scheduled`: created with a future `scheduled_at` and not yet executed; `scheduled_at` carries the pending execution time. Reschedulable via PATCH /sent-emails/{id} and cancelable via /sent-emails/{id}/cancel while in this status. - `canceled`: terminal; a scheduled send canceled before execution. `canceled_at` carries the cancellation time and nothing was dispatched.
          - `status_changed_at` string, date-time, required: Timestamp of the most recent status transition. Polling clients should treat `status='queued'` AND `status_changed_at` older than 5 minutes as "stuck-queued" (the post-tx UPDATE failed and the actual delivery state is recoverable from on-box logs via `queue_id` when populated, or `request_id`).
          - `created_at` string, date-time, required
          - `updated_at` string, date-time, required
          - `client_idempotency_key` string, nullable: Effective idempotency key used for this send. If the caller passed the `Idempotency-Key` header, this is that value; otherwise it's a server-derived hash of the canonical request payload.
          - `content_hash` string, required: Stable hash of the canonical send payload.
          - `from_header` string, required: Raw `From:` header as sent on the wire, including any display name (e.g. `"Acme Support" <agent@acme.test>`).
          - `from_address` string, required: Bare email address parsed from `from_header`.
          - `to_header` string, required: Raw `To:` header as sent on the wire, including any display name.
          - `to_address` string, required: Bare email address parsed from `to_header`.
          - `subject` string, required
          - `body_size_bytes` integer, required: Total UTF-8 byte length of `body_text` + `body_html`. Surfaced on the list endpoint so callers can see "this row has a 4MB body" without fetching it.
          - `content_discarded_at` string, date-time, nullable: Timestamp at which the bodies were discarded by an entitlement-driven retention policy. Null when bodies are still present. The detail endpoint returns null-valued `body_text`/`body_html` for discarded rows.
          - `message_id` string, nullable: Wire-level Message-ID assigned to the outbound message (RFC 5322). Null on rows that never reached signing (queued, gate_denied, agent_failed before signing).
          - `in_reply_to` string, nullable: Wire-level In-Reply-To header value, when this send was a reply.
          - `email_references` string, nullable: Wire-level References header value, when this send was a reply.
          - `in_reply_to_email_id` string, uuid, nullable: Reference to the inbound `emails.id` that this send replied to, when known. Populated when the caller used /emails/{id}/reply or when /send-mail's `in_reply_to` matched a stored inbound message_id in the same org.
          - `thread_id` string, uuid, nullable: Conversation thread this send belongs to. A reply inherits the thread of the inbound it answers; a fresh send starts a new thread. Fetch `/threads/{thread_id}` for the full ordered thread (inbound + outbound interleaved). NULL on gate-denied sends and on sends created before threading was enabled.
          - `queue_id` string, nullable: Message identifier assigned by Primitive's outbound relay once the agent accepts the message. Null on queued, gate_denied, and agent_failed rows.
          - `smtp_response_code` integer, nullable: Receiver's 3-digit SMTP code (e.g. 250, 550, 451). Populated on terminal delivery statuses; may be null on a deferred where the agent never got an SMTP-level response (TCP refused, DNS failed, TLS handshake failed). `smtp_response_text` still carries Postfix's descriptive text in those cases.
          - `smtp_response_text` string, nullable: Free-form text portion of the receiver's SMTP response. The most useful debugging signal on a `bounced` or `deferred` row.
          - `smtp_enhanced_status_code` string, nullable: RFC 3463 enhanced status code (e.g. `5.1.1` for "Bad destination mailbox address"). Distinct from `smtp_response_code`: the basic 3-digit code is coarse (550 = "permanent failure"), the enhanced code is finer-grained.
          - `dkim_selector` string, nullable: DKIM selector used to sign the outbound message. Public DNS data; useful for diagnosing why a downstream verifier rejected the signature.
          - `dkim_domain` string, nullable: DKIM signing domain.
          - `error_code` string, nullable: Stable public error code on `agent_failed` rows. The agent's internal codes are remapped to a stable public taxonomy (see `publicAgentError` in the server) so this field is safe to branch on across agent versions.
          - `error_message` string, nullable: Free-form error message accompanying `error_code`.
          - `gates` GateDenial[], nullable: Gate-denial detail on `gate_denied` rows. Mirrors the synchronous /send-mail 403 contract so a caller's GateDenial handler is the same across live denies and historical lookups. Null on every other status.
            - `name` 'send_to_confirmed_domains' | 'send_to_known_addresses', required: Public recipient-scope gate name that denied the send.
            - `reason` 'domain_not_confirmed' | 'recipient_unauthenticated' | 'recipient_not_known', required: Stable machine-readable denial reason.
            - `message` string, required: Human-readable explanation of the gate denial.
            - `subject` string, required: Domain or address the gate evaluated.
            - `fix` GateFix
              - …
            - `docs_url` string: Public docs URL with more context.
          - `request_id` string, nullable: Server-issued request identifier from the original /send-mail call. Surfaced as the `X-Request-Id` response header on the live send and recorded here for support escalation.
          - `scheduled_at` string, date-time, nullable: Requested execution time for a scheduled send. Kept after execution as the historical schedule; null on ordinary immediate sends.
          - `canceled_at` string, date-time, nullable: When a scheduled send was canceled. Null unless the row reached the `canceled` status.
          - `presence_control` PresenceControl, nullable
            - `status` 'verified' | 'pending' | 'rejected', required
            - `valid_for_ms` integer, required
          - `repeat` object, nullable: Set when Primitive sent this message as part of a repeating send. Resolved from Primitive's own records, never from the message content. Null on every other message and on servers that predate repeating sends.
            - `repeat_id` string, uuid, required
            - `sequence` integer, required
          - `sender_member` object, nullable
            - `address` string, email, required
            - `user_id` string, required
            - `name` string, nullable, required
          - `delivery_recipients` object[], nullable: Per-recipient SMTP outcomes, including partial failures even when the aggregate send status is delivered. Sender-only; empty when withheld from another recipient or when legacy metadata has no outcomes. Null means stored delivery metadata is unreadable. Optional on older payloads.
            - `address` string, required
            - `result` 'delivered' | 'bounced' | 'deferred', required
            - `smtp_response_text` string, nullable, required
            - `smtp_enhanced_status_code` string, nullable, required
          - `body_text` string, nullable: Plain-text body sent on the wire. Null when the send carried only an HTML body, or when bodies have been discarded post-send (`content_discarded_at` set).
          - `body_html` string, nullable: HTML body sent on the wire. Null when the send carried only a plain-text body, or when bodies have been discarded post-send.
          - `headers` IncludedMessageHeaders: Stored addressing and threading headers. A member is null when absent or withheld.
            - `from` string, nullable, required
            - `to` string[], nullable, required
            - `cc` string[], nullable, required
            - `bcc` string[], nullable, required
            - `reply_to` string[], nullable, required
            - `in_reply_to` string[], nullable, required
            - `references` string[], nullable, required
            - `automation` object, nullable, required: Received mail only.
          - `attachments` object[]: Metadata for submitted inline attachments; download availability is reported separately. Metadata may exist when the archive is unavailable, and legacy sends may have no metadata. Offloaded payload files are not included in the inline archive.
            - `filename` string, nullable, required
            - `content_type` string, required
            - `size_bytes` integer, required
            - `sha256` string, required
            - `part_index` integer, required
            - `tar_path` string, required
          - `truncated` boolean: With `include`: true when a body on this row was cut or left out.
          - `truncated_fields` string[]: With `include`: the body fields on this row that were cut or left out.
          - `interaction_hint` 'status' | 'card' | 'none', required: How to place this send in a timeline without downloading it, decided when it was sent from the `interaction.json` part it carried (no authentication check: the sending organization wrote it). Same values as on received email except that a send is never `pending`. Decided once by the server from the message's authoritative `interaction.json` part, and for received mail only when a passing DKIM signature covering From and Content-Type authenticates the From that wrote it; the `X-Primitive-Interaction` header is never used, so an unsigned or forged message is always `none`. `status`: a pure status signal (`read`, `working`, `typing`, or an `ack` that is the strict signal shape), to be shown as a property of the message it is about, not as a row. `card`: an interaction that renders as its own timeline item (`ack-request`, `wake.dispatch`, `primitive.contact`, `repeat.tick`, `repeat.stop`, and the reserved `approval` and `schedule.send`). `none`: ordinary mail, including any interaction kind not listed. Treat an unfamiliar value as `none`.
          - `interaction_kind` string, nullable, required: The `<protocol>/<version>` the hint was decided from when `interaction_hint` is `status` or `card`; null otherwise.
          - `snippet` string, nullable, required: A plain-text preview of `body_text` (never `body_html`), computed once when the message is stored: quoted replies (an "On ... wrote:" line, an "Original Message" separator, a From/Sent header block, `>` lines) and a `-- ` signature are cut off, whitespace is collapsed to single spaces, and the result is at most 200 characters. A message with no text body, or with nothing left once quotes and the signature are cut (a quote-only reply), has no snippet. It is not a summary and may end mid-word; read the full body from the detail endpoint. Null when the send has no text body or its content was discarded.
          - `recipient_count` integer, nullable: Every recipient of the send, To, Cc and Bcc together, each address counted once: the size of `to_addresses`, `cc` and `bcc` combined without duplicates. On a mail relay send `bcc` lists every hidden recipient. Null when the request uses an address-bound agent connection key whose address is not the sender, because the count would reveal Bcc recipients.
          - `hold` object: Only on a held scheduled send: its OAuth grant was revoked after refresh token reuse, so it waits until released (POST /sent-emails/{id}/release) or canceled; canceled after 7 days. Status stays scheduled.
            - `reason` string, required: Open: credential_reuse_hold.
            - `since` string, date-time, nullable, required
          - `direction` 'outbound', required
    - `next_cursor` string, required: Always present. Send it unchanged as `since` next time, and store it only after applying the page. It is exactly the `since` you sent when nothing at all happened after it; it moves past changes this credential cannot see, so the page can be empty with a new cursor.
    - `has_more` boolean, required: More changes are already pending: call again at once, without waiting.
    - `baseline` boolean, required: True only for a `since=start` request, whose page is always empty.

## Other responses

- `400`: `validation_error` for a bad parameter, or `invalid_cursor` for a cursor that is malformed or belongs to another organization. Rebuild from `since=start`.
- `401`: Invalid or missing API key
- `410`: `cursor_expired`: changes after this cursor were pruned. `error.details.retention_seconds` is the window. Rebuild the snapshots from `since=start`.
- `429`: Rate limit exceeded. The `ratelimit-*` headers describe the limiter that rejected this request, with its own limit and window, not the API budget that successful responses report. A limiter that reports only a wait sends `Retry-After` alone.

## Changes

> 32 revisions in range; 1 not diffed.

- **2026-11-03** `bce409c97acb`: 124 warning
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `400`
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `401`
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `410`
  - added the new `access_mode_unavailable` enum value to the `error/code` response property for the response status `429`
  - …120 more
  - …this revision’s changelog is incomplete
- **2026-10-21** `0360ea8d4f3d`: 52 warning
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `400`
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `401`
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `410`
  - added the new `agent_changed` enum value to the `error/code` response property for the response status `429`
  - …48 more
- …earlier changes not shown

[Operation history](https://skmtc.dev/primitive/apis/primitive-api/changes/changes/get.md)

---

[API](https://skmtc.dev/primitive/apis/primitive-api.md) - [All operations](https://skmtc.dev/primitive/apis/primitive-api/llms.txt) - [OpenAPI document](https://skmtc.dev/primitive/apis/primitive-api/revisions/bce409c97acb?raw)
