Primitive Emails API
List, inspect, and manage received emails
List, inspect, and manage received emails
Every API here is available over the APIs.io API and to AI agents over MCP.
One button, every client — Claude, Cursor, VS Code and the rest.
https://apis.io/mcp
find_apisBrowse and filter every API in the catalog.get_api_artifactsOne API's artifacts, grouped by type.get_openapiThe primary OpenAPI for this API.find_similar_apisAPIs that look like this one.apis_io_searchSTART HERE — APIs, providers and tags for one query, each with its total.resolveTurn a domain, URL or GitHub org into the provider it belongs to.find_cohortsEvery scored population of providers in the catalog.curl "https://apis.io/api/v1/apis/primitive-emails-api"
curl "https://apis.io/api/v1/apis?limit=25"
Discovery needs no key. Ratings and market analysis are Pro.
openapi: 3.2.0
info:
title: Primitive Account Emails API
version: 1.0.0
description: "Primitive is email infrastructure for AI agents. The Primitive API lets you manage domains, emails, webhook endpoints,\nfilters, and account settings programmatically.\n\n## Authentication\n\nMost endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer prim_<your_api_key>\nAuthorization: Bearer prim_oat_<oauth_access_token>\n```\n\nAPI keys and OAuth access tokens are org-scoped. Create and manage them in your dashboard\nunder Settings > API Keys. CLI login plus CLI/agent signup endpoints\nexplicitly declare `security: []`; they do not require an API key because\nthey are used to create OAuth CLI sessions.\n\n## Rate Limiting\n\nThe API enforces a sliding window rate limit of **120 requests per\n60 seconds** per organization. When exceeded, the API returns `429`\nwith a `Retry-After` header indicating how many seconds to wait.\n\n## Pagination\n\nList endpoints use cursor-based pagination. Responses include a\n`meta` object with `total`, `limit`, and `cursor` fields. Pass the\n`cursor` value as a query parameter to fetch the next page. When\n`cursor` is `null`, there are no more results.\n\n## Response Format\n\nAll responses use a consistent envelope:\n\n```json\n{\n \"success\": true,\n \"data\": { ... },\n \"meta\": { \"total\": 42, \"limit\": 50, \"cursor\": \"...\" }\n}\n```\n\nErrors follow the same pattern:\n\n```json\n{\n \"success\": false,\n \"error\": { \"code\": \"not_found\", \"message\": \"Email not found\" }\n}\n```\n\n## Webhook signing\n\nOutbound webhook deliveries (configured via the `endpoints` API)\nare signed so receivers can verify they came from Primitive and\nhave not been tampered with in transit. The signing scheme is\ndeliberately simple so it can be reimplemented in any language\nin a few lines. The Node SDK's `verifyWebhookSignature` helper\nis the reference implementation; the wire details below let you\nwrite a verifier in Python, Go, Ruby, etc. without reading our\nsource.\n\n**Header**: `Primitive-Signature: t=<unix-seconds>,v1=<hex>`\n\nA legacy `MyMX-Signature` header is also sent on every delivery\nwith the same value, retained for back-compatibility with\nintegrations written before the rename. New code should read\n`Primitive-Signature`.\n\n**Signed string**: `${timestamp}.${rawBody}` where `timestamp`\nis the Unix-seconds integer from the `t=` parameter and\n`rawBody` is the exact bytes of the HTTP request body BEFORE\nany JSON decoding. Verify against the raw body, not a\nre-serialized parse, or you will silently mismatch on\ninsignificant whitespace.\n\n**Signature**: HMAC-SHA256 of the signed string, hex-encoded\n(lowercase). Use the account's webhook secret as the HMAC key,\nas a UTF-8 byte sequence.\n\n**Secret**: returned by `GET /account/webhook-secret`. The\nstring looks base64-shaped (e.g. `XNHBBW8VqoBjRfNs1tkZj11jTk...`)\nbut is NOT base64; use it AS-IS as a UTF-8 string for the HMAC\nkey. Base64-decoding before HMAC will silently produce\nmismatched signatures.\n\n**Tolerance**: by convention, reject deliveries whose `t=`\ntimestamp is more than 5 minutes off your wall-clock to defend\nagainst replay attacks. The Node SDK's helper enforces this by\ndefault.\n\n**Verification recipe** (any language):\n\n```\n1. Read the raw HTTP body (do not parse).\n2. Read `Primitive-Signature: t=<ts>,v1=<sig>`.\n3. Reject if abs(now - ts) > 300 seconds.\n4. expected = HMAC_SHA256_hex(secret_utf8, f\"{ts}.{rawBody}\")\n5. Constant-time compare expected to sig. Reject if not equal.\n```\n\nFor Node, use `verifyWebhookSignature` from\n`@primitivedotdev/sdk/webhook` (or the higher-level\n`handleWebhook` helper if you want a one-liner). For other\nlanguages, the recipe above is everything you need.\n\nTest deliveries: `POST /endpoints/{id}/test` triggers a fake\ndelivery to your endpoint URL, signed with your real account\nsecret, so you can confirm verification end-to-end without\nneeding real inbound mail. The test response carries the exact\n`signature` header value sent on the wire so you can compare\nstrings directly.\n\n\n## Errors\n\nEvery error response is the same JSON envelope (`{ \"success\": false, \"error\": { \"code\", \"message\" } }`), served as `application/json` with HTTP status codes, following the RFC 7807 problem-details shape. The `error.code` is a stable machine-readable string and `error.message` is human-readable.\n\n## Authorization and roles\n\nAccess is governed by organization role-based access control. Every organization member holds one of three roles — `owner`, `admin`, or `member` — and a credential inherits a role. **API keys** always act at `member` level, regardless of the role of the user who created them, so an API key can never perform owner- or admin-only actions. **OAuth access tokens** act with the authorizing user's current organization role, resolved on each request. Every operation in this spec is part of the member-level surface, so any valid credential can call it. Organization administration that is not part of this API — billing and organization settings — requires an `owner` or `admin` and is performed in the dashboard. Fine-grained per-key scopes (e.g. a send-only or read-only key) are on the roadmap; today the role model is the unit of access control.\n\n## Versioning\n\nThe current stable API is **v1**. All endpoints are served under `/v1/` and are covered by a backward-compatibility guarantee: existing fields and status codes will not change without a deprecation notice.\n\nBreaking changes are announced at least 6 months in advance via changelog and email. Deprecated operations and fields are marked `x-deprecated: true` in the spec and carry a plain-English description of the replacement. The `v1` path prefix is guaranteed stable indefinitely; backward-compatible additions (new optional fields, new endpoints) may be made at any time without a version bump."
contact:
name: Primitive
url: https://primitive.dev
license:
name: Proprietary
url: https://primitive.dev/terms
x-stability-level: stable
x-deprecation-policy: 'Breaking changes are announced at least 6 months in advance. Deprecated fields carry x-deprecated: true. The current stable version is v1.'
servers:
- url: https://api.primitive.dev/v1
description: Canonical API host (PRIMITIVE_API_BASE_URL). Carries every public API operation.
tags:
- name: Emails
description: List, inspect, and manage received emails
paths:
/emails:
get:
operationId: listEmails
summary: List inbound emails
description: 'Returns a paginated list of INBOUND emails received at your
verified domains. Outbound messages sent via /send-mail are
not included; this endpoint is the inbox view, not a
unified send/receive history.
Supports filtering by domain, status, date range, and
free-text search across subject, sender, and recipient
fields.
For a compact text-table summary of the most recent N
inbounds (no filters, no cursor pagination), the CLI ships
`primitive emails:latest` as a one-line-per-email shortcut.
It''s TTY-aware so id columns are full UUIDs when piped, and
a `--json` flag returns the same envelope this endpoint
does. Use whichever fits the call site.
'
tags:
- Emails
parameters:
- name: cursor
in: query
schema:
type: string
description: 'Pagination cursor from a previous response''s `meta.cursor` field.
Format: `{ISO-datetime}|{id}`
'
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 50
description: Number of results per page
- name: domain_id
in: query
schema:
type: string
format: uuid
description: Filter by domain ID
- name: status
in: query
schema:
type: string
description: "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n"
enum:
- pending
- accepted
- completed
- rejected
description: 'Filter inbound rows by lifecycle status. See `EmailStatus`
for what each value means. Note that the webhook delivery
state is a SEPARATE lifecycle on the same row; filter by
`webhook_status` semantics is not currently supported on
this endpoint.
'
- name: search
in: query
schema:
type: string
maxLength: 500
description: Search subject, sender, and recipient (case-insensitive)
- name: date_from
in: query
schema:
type: string
format: date-time
description: Filter emails created on or after this timestamp
- name: date_to
in: query
schema:
type: string
format: date-time
description: Filter emails created on or before this timestamp
- name: since
in: query
schema:
type: string
maxLength: 200
description: 'Forward-tail cursor. Returns rows that became visible AFTER this
cursor, oldest-first, so a caller can stream new inbound mail by
re-passing the cursor from each response. Mutually exclusive with
`cursor` (which pages history newest-first). Pass the `meta.cursor`
from the previous `since` response; an empty page means caught up.
'
- name: wait
in: query
schema:
type: integer
minimum: 0
maximum: 30
description: 'Long-poll: hold the request up to this many seconds waiting for new
mail past `since`, returning as soon as any arrives (or an empty
page when the wait elapses). Requires `since`. Omitted means no wait
(returns immediately); the server treats an absent value as 0. NOT
given an OpenAPI `default` on purpose: a default makes some
generators (e.g. openapi-python-client) send `wait=0` on every call,
which then fails the `wait` requires `since` check for plain history
listings.
'
responses:
'200':
description: Paginated list of emails
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
meta:
type: object
properties:
total:
type: integer
description: Total number of matching records
limit:
type: integer
description: Page size used for this request
cursor:
type:
- string
- 'null'
description: Cursor for the next page, or null if no more results
required:
- total
- limit
- cursor
required:
- success
- data
- meta
- type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
format: uuid
message_id:
type:
- string
- 'null'
domain_id:
type:
- string
- 'null'
format: uuid
org_id:
type:
- string
- 'null'
format: uuid
status:
type: string
description: "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n"
enum:
- pending
- accepted
- completed
- rejected
sender:
type: string
description: '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:
type: string
subject:
type:
- string
- 'null'
domain:
type: string
spam_score:
type:
- number
- 'null'
created_at:
type: string
format: date-time
received_at:
type: string
format: date-time
raw_size_bytes:
type:
- integer
- 'null'
webhook_status:
type:
- string
- 'null'
description: "Webhook-delivery state for an inbound email. Tracks a\nSEPARATE lifecycle from the email's `status` field; the\nsame row carries both. Possible values:\n\n - `pending`: ingestion is past `pending` (the email itself\n is `accepted`) but the webhook fan-out has not yet\n started for this row.\n - `in_flight`: at least one delivery attempt is in flight.\n - `fired`: terminal success. Every active endpoint\n acknowledged the delivery (or accepted it after retries).\n - `failed`: terminal partial-failure. At least one endpoint\n exhausted its retry budget; some endpoints may still\n have succeeded.\n - `exhausted`: terminal failure. Every endpoint exhausted\n its retry budget without success.\n - `null`: no endpoints configured, so no webhook lifecycle\n applies.\n\nNote that the value `pending` here does NOT mean the email\nis `pending`; it means the email is past ingestion but\nwebhook delivery has not yet begun. Two overlapping uses\nof the word `pending` for distinct lifecycle phases.\n"
enum:
- pending
- in_flight
- fired
- failed
- exhausted
- null
webhook_attempt_count:
type: integer
thread_id:
type:
- string
- 'null'
format: uuid
description: 'Conversation thread this message belongs to. Fetch
`/threads/{thread_id}` for the full ordered thread. NULL on
messages received before threading was enabled.
'
required:
- id
- status
- sender
- recipient
- domain
- created_at
- received_at
- webhook_attempt_count
headers:
ratelimit-limit:
description: Maximum number of requests allowed in the current window.
schema:
type: integer
minimum: 1
example: 120
ratelimit-remaining:
description: Remaining requests in the current window.
schema:
type: integer
minimum: 0
example: 118
ratelimit-reset:
description: Unix timestamp (seconds) when the current window resets.
schema:
type: integer
example: 1700000060
ratelimit-policy:
description: Rate-limit policy in `limit;w=seconds` format, e.g. `120;w=60`.
schema:
type: string
example: 120;w=60
'400':
$ref: '#/components/responses/ValidationError'
description: Invalid request parameters
'401':
$ref: '#/components/responses/Unauthorized'
description: Invalid or missing API key
security:
- BearerAuth: []
/emails/search:
get:
operationId: searchEmails
summary: Search inbound emails
description: 'Searches inbound emails with structured filters and optional
full-text matching across parsed email fields. This endpoint is
optimized for filtered inbox views and CLI polling workflows:
callers that only need new accepted mail can pass
`sort=received_at_asc`, `snippet=false`, `include_facets=false`,
and a `date_from` timestamp.
`q`, `subject`, and `body` use the same English full-text index
as the web inbox search. Structured filters such as `from`, `to`,
`domain_id`, status, attachment presence, and spam score bounds
are combined with the text query.
'
tags:
- Emails
parameters:
- name: q
in: query
schema:
type: string
maxLength: 500
description: Full-text search DSL query.
- name: from
in: query
schema:
type: string
maxLength: 255
description: Filter by sender address or sender domain.
- name: to
in: query
schema:
type: string
maxLength: 255
description: Filter by recipient address or recipient domain.
- name: subject
in: query
schema:
type: string
maxLength: 500
description: Full-text search restricted to the subject field.
- name: body
in: query
schema:
type: string
maxLength: 2000
description: Full-text search restricted to the parsed text body.
- name: domain_id
in: query
schema:
type: string
format: uuid
description: Filter by domain ID.
- name: reply_to_sent_email_id
in: query
schema:
type: string
format: uuid
description: 'Filter to inbound emails that are replies to a specific
outbound send. The value is a `sent_emails.id` (UUID). At
inbound ingest, Primitive matches the parsed In-Reply-To
header (or References as a fallback) against
`sent_emails.message_id` in the same org and records the
resolved id on `emails.reply_to_sent_email_id`. This filter
is the strict-threading lookup behind `primitive chat` and
any UI that wants to show the inbound reply to a given
send. NULL on inbound that isn''t a threaded reply to one
of your sends, so existing emails received before this
ingestion landed will not match.
'
- name: status
in: query
schema:
type: string
description: "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n"
enum:
- pending
- accepted
- completed
- rejected
description: Filter by inbound email lifecycle status.
- name: date_from
in: query
schema:
type: string
format: date-time
description: Filter emails received on or after this timestamp.
- name: date_to
in: query
schema:
type: string
format: date-time
description: Filter emails received on or before this timestamp.
- name: has_attachment
in: query
schema:
type: string
enum:
- 'true'
- 'false'
description: Filter by whether the email has one or more attachments.
- name: spam_score_lt
in: query
schema:
type: number
description: Filter to emails with spam score below this value.
- name: spam_score_gte
in: query
schema:
type: number
description: Filter to emails with spam score greater than or equal to this value.
- name: sort
in: query
schema:
type: string
enum:
- relevance
- received_at_desc
- received_at_asc
description: 'Sort mode. Defaults to relevance when a text query is present,
otherwise `received_at_desc`.
'
- name: cursor
in: query
schema:
type: string
maxLength: 200
description: Opaque pagination cursor from a previous search response.
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 50
description: Number of results per page
- name: snippet
in: query
schema:
type: string
enum:
- 'true'
- 'false'
default: 'true'
description: Include subject/body highlight snippets when text search is active.
- name: include_facets
in: query
schema:
type: string
enum:
- 'true'
- 'false'
default: 'true'
description: Include facet counts for sender, domain, status, and attachment presence.
responses:
'200':
description: Search results
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
required:
- success
- data
- type: object
properties:
data:
type: array
items:
allOf:
- type: object
properties:
id:
type: string
format: uuid
message_id:
type:
- string
- 'null'
domain_id:
type:
- string
- 'null'
format: uuid
org_id:
type:
- string
- 'null'
format: uuid
status:
type: string
description: "Lifecycle status of an INBOUND email (a row in the `emails`\ntable). Distinct from `SentEmailStatus`, which describes\nthe OUTBOUND lifecycle (the `sent_emails` table) and uses\na different vocabulary because the lifecycles differ.\nPossible values:\n\n - `pending`: the row was inserted at ingestion (mx_main)\n and has not yet completed the spam / filter / auth\n pipeline. Body and parsed fields are present; webhook\n delivery is not yet scheduled. Most rows transition out\n of `pending` within seconds.\n - `accepted`: the inbound passed the policy gates and is\n queued for webhook delivery. The `webhook_status` field\n tracks the separate webhook-delivery lifecycle from\n this point.\n - `completed`: terminal success. Webhook delivery\n attempted and acknowledged by every active endpoint, OR\n no endpoints are configured, so the row is durably\n archived.\n - `rejected`: terminal failure at ingestion (spam, blocked\n sender, filter rule, malformed). The body and metadata\n are stored for auditing but no webhook fires and the\n row is not repliable.\n\nSee also `webhook_status` (separate enum tracking the\nwebhook-delivery state machine) and `SentEmailStatus` (the\noutbound vocabulary).\n"
enum:
- pending
- accepted
- completed
- rejected
sender:
type: string
description: '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:
type: string
subject:
type:
- string
- 'null'
domain:
type: string
spam_score:
type:
- number
- 'null'
created_at:
type: string
format: date-time
received_at:
type: string
format: date-time
raw_size_bytes:
type:
- integer
- 'null'
webhook_status:
type:
- string
- 'null'
description: "Webhook-delivery state for an inbound email. Tracks a\nSEPARATE lifecycle from the email's `status` field; the\nsame row carries both. Possible values:\n\n - `pending`: ingestion is past `pending` (the email itself\n is `accepted`) but the webhook fan-out has not yet\n started for this row.\n - `in_flight`: at least one delivery attempt is in flight.\n - `fired`: terminal success. Every active endpoint\n acknowledged the delivery (or accepted it after retries).\n - `failed`: terminal partial-failure. At least one endpoint\n exhausted its retry budget; some endpoints may still\n have succeeded.\n - `exhausted`: terminal failure. Every endpoint exhausted\n its retry budget without success.\n - `null`: no endpoints configured, so no webhook lifecycle\n applies.\n\nNote that the value `pending` here does NOT mean the email\nis `pending`; it means the email is past ingestion but\nwebhook delivery has not yet begun. Two overlapping uses\nof the word `pending` for distinct lifecycle phases.\n"
enum:
- pending
- in_flight
- fired
- failed
- exhausted
- null
webhook_attempt_count:
type: integer
# --- truncated at 32 KB (102 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/primitive/refs/heads/main/openapi/primitive-emails-api-openapi.yml