Primitive Emails API

List, inspect, and manage received emails

OpenAPI Specification

primitive-emails-api-openapi.yml Raw ↑
openapi: 3.1.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