Primitive Sending API

Send outbound emails through the Primitive API

OpenAPI Specification

primitive-sending-api-openapi.yml Raw ↑
openapi: 3.1.0
info:
  title: Primitive Account Sending 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: Sending
  description: Send outbound emails through the Primitive API
paths:
  /emails/{id}/reply:
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Resource UUID
    post:
      operationId: replyToEmail
      summary: Reply to an inbound email
      description: 'Sends an outbound reply to the inbound email identified by `id`.

        Threading headers (`In-Reply-To`, `References`), recipient

        derivation (Reply-To, then From, then bare sender), and the

        `Re:` subject prefix are all derived server-side from the

        stored inbound row. The request body carries only the message

        body, optional From override, optional attachments, and optional

        `wait` flag; passing any header or recipient override is

        rejected by the schema (`additionalProperties: false`).


        Forwards through the same gates as `/send-mail`: the response

        status, error envelope, and `idempotent_replay` flag mirror

        the send-mail contract verbatim.

        '
      servers:
      - url: https://api.primitive.dev/v1
        description: Canonical API host (recommended)
      - url: https://www.primitive.dev/api/v1
        description: Legacy compatibility host (Vercel body limit applies)
      tags:
      - Sending
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              description: 'Body shape for `/emails/{id}/reply`. Intentionally narrow:

                recipients (`to`), subject, and threading headers

                (`in_reply_to`, `references`) are derived server-side from

                the inbound row referenced by the path id and are rejected by

                `additionalProperties` if passed (returns 400).


                `from` IS allowed because of legitimate use cases (display-name

                addition, replying from a different verified outbound address,

                multi-team triage). Send-mail''s per-send `canSendFrom` gate

                validates the from-domain regardless, so the override carries

                no extra privilege.

                '
              properties:
                body_text:
                  type: string
                  description: Plain-text reply body. At least one of body_text or body_html is required. The combined UTF-8 byte length of body_text and body_html must be at most 262144 bytes (same cap as send-mail).
                body_html:
                  type: string
                  description: HTML reply body. At least one of body_text or body_html is required.
                from:
                  type: string
                  minLength: 3
                  maxLength: 998
                  description: 'Optional override for the reply''s From header. Defaults to

                    the inbound''s recipient. Use to add a display name (`"Acme

                    Support" <agent@company.com>`) or to reply from a different

                    verified outbound address (e.g. multi-team routing where

                    support@ triages to billing@). The from-domain must be a

                    verified outbound domain for your org, same as send-mail.

                    '
                wait:
                  type: boolean
                  description: When true, wait for the first downstream SMTP delivery outcome before returning, mirroring the send-mail `wait` semantics.
                attachments:
                  type: array
                  maxItems: 100
                  description: Inline attachments for this reply. Use https://api.primitive.dev/v1 for replies with attachments. Combined raw decoded attachment bytes must be at most 31457280.
                  items:
                    type: object
                    additionalProperties: false
                    properties:
                      filename:
                        type: string
                        minLength: 1
                        maxLength: 255
                        description: Attachment filename. Control characters are rejected.
                      content_type:
                        type: string
                        minLength: 1
                        maxLength: 255
                        description: Optional MIME content type. Control characters are rejected.
                      content_base64:
                        type: string
                        minLength: 1
                        maxLength: 44040192
                        description: Base64-encoded attachment bytes.
                    required:
                    - filename
                    - content_base64
      responses:
        '200':
          description: Outbound relay result
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    success:
                      type: boolean
                      const: true
                  required:
                  - success
                  - data
                - type: object
                  properties:
                    data:
                      type: object
                      properties:
                        id:
                          type: string
                          description: Persisted sent-email attempt ID.
                        status:
                          type: string
                          description: "Lifecycle status of a sent_emails row. Possible values:\n\n  - `queued`: pre-call INSERT; the outbound agent has not\n    yet replied.\n  - `submitted_to_agent`: agent accepted; `queue_id` is set.\n  - `agent_failed`: agent rejected; `error_code` and\n    `error_message` carry the reason.\n  - `gate_denied`: a recipient-scope gate denied the send;\n    the agent was never called. The `gates` array carries\n    the denial detail. /send-mail returns 403 in this case\n    so callers see the denial synchronously; /sent-emails\n    additionally records the row for historical lookup,\n    which is when this status appears in a listing.\n  - `unknown`: terminal indeterminate; the on-box log\n    poller couldn't classify the receiver's response.\n  - `delivered` / `bounced` / `deferred` / `wait_timeout`:\n    terminal delivery outcomes (see DeliveryStatus).\n"
                          enum:
                          - queued
                          - submitted_to_agent
                          - agent_failed
                          - gate_denied
                          - unknown
                          - delivered
                          - bounced
                          - deferred
                          - wait_timeout
                        from:
                          type: string
                          description: 'Bare from-address actually written on the wire. Echoed

                            on every success branch so callers can confirm what

                            went out, particularly useful for the /emails/{id}/reply

                            path where `from` is server-derived from the inbound''s

                            recipient when the caller doesn''t override.


                            For sends where the caller passed a from-header that

                            included a display name (e.g. `"Acme Support" <support@acme.test>`),

                            this field is the parsed bare address (`support@acme.test`).

                            The display name was sent on the wire intact; this field

                            just makes the address easy to compare against allowlists.

                            '
                        queue_id:
                          type:
                          - string
                          - 'null'
                          description: 'Message identifier assigned by Primitive''s OUTBOUND relay

                            (the box that signs your mail and submits it to the

                            receiving MTA). NOT the receiver''s queue id.


                            The receiver may also report its own queue id in

                            `smtp_response_text` (e.g. `"250 2.0.0 Ok: queued as

                            99D111927CDA"` from a Postfix receiver). Those two ids

                            refer to different mail systems and are NOT comparable.

                            Treat `queue_id` as Primitive-internal and the

                            receiver''s id as remote-system-internal.


                            Null on rows that never reached the relay (queued,

                            gate_denied, agent_failed before signing).

                            '
                        accepted:
                          type: array
                          items:
                            type: string
                          description: Recipient addresses accepted by the relay.
                        rejected:
                          type: array
                          items:
                            type: string
                          description: Recipient addresses rejected by the relay.
                        client_idempotency_key:
                          type: string
                          description: Effective idempotency key used for this send.
                        request_id:
                          type: string
                          description: Server-issued request identifier for support and tracing.
                        content_hash:
                          type: string
                          description: Stable hash of the canonical send payload.
                        delivery_status:
                          type: string
                          description: 'Narrower enum covering only the four terminal delivery

                            outcomes returned to a synchronous `wait: true` send.


                            On the SendMailResult shape, `delivery_status` is always

                            equal to `status` whenever both are present (i.e. on

                            terminal-state replays and live wait=true responses).

                            The two fields exist so callers that want to type-narrow

                            on "this is a delivery outcome" can pattern-match against

                            the four-value enum without handling the broader

                            SentEmailStatus value set (which also covers `queued`,

                            `submitted_to_agent`, `agent_failed`, `gate_denied`,

                            `unknown`).


                            On async-mode and pre-terminal responses, `delivery_status`

                            is absent and only `status` is populated. Use `status` if

                            you want a single field that''s always present.

                            '
                          enum:
                          - delivered
                          - bounced
                          - deferred
                          - wait_timeout
                        smtp_response_code:
                          type:
                          - integer
                          - 'null'
                          description: SMTP response code from the first downstream delivery outcome when wait is true.
                        smtp_response_text:
                          type: string
                          description: SMTP response text from the first downstream delivery outcome when wait is true.
                        idempotent_replay:
                          type: boolean
                          description: 'True when the response replays a previously-recorded send

                            keyed by `client_idempotency_key` (same key, same canonical

                            payload). False on a fresh send and on gate-denied

                            responses. Lets callers branch on cache state without

                            diffing fields.

                            '
                      required:
                      - id
                      - status
                      - from
                      - queue_id
                      - accepted
                      - rejected
                      - client_idempotency_key
                      - request_id
                      - content_hash
                      - idempotent_replay
          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
        '403':
          $ref: '#/components/responses/Forbidden'
          description: Authenticated caller lacks permission for the operation
        '404':
          $ref: '#/components/responses/NotFound'
          description: Resource not found
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
          description: 'Inbound is not repliable: the row exists but lacks a

            `message_id` (no thread anchor) or a `recipient` (cannot

            derive the From address).

            '
        '429':
          $ref: '#/components/responses/RateLimited'
          description: Rate limit exceeded
        '500':
          $ref: '#/components/responses/InternalError'
          description: Primitive encountered an internal error
        '502':
          $ref: '#/components/responses/BadGateway'
          description: Primitive could not complete the downstream SMTP request
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
          description: Primitive is temporarily unable to process the request
      security:
      - BearerAuth: []
      parameters:
      - name: Idempotency-Key
        in: header
        required: false
        description: Optional client-supplied idempotency key. Retrying a request with the same key returns the original result instead of performing the action a second time; if omitted the server derives one from the canonical payload hash. Safe to retry network failures without duplicating side effects.
        schema:
          type: string
          minLength: 1
          maxLength: 255
  /send-permissions:
    get:
      operationId: getSendPermissions
      summary: List send-permission rules
      description: "Returns a flat list of rules describing every recipient the\ncaller may send to. Each rule has a `type`, a kind-specific\npayload, and a human-readable `description`. If any rule\nmatches the recipient, /send-mail will accept the send under\nthe recipient-scope check.\n\nThe endpoint is the answer to \"where can I send\" without\nexposing internal entitlement names. Agents that don't\nrecognize a `type` can still read the `description` prose\nand act on it.\n\nRule kinds, ordered broadest-first so an agent can stop\nscanning at the first match:\n\n  1. `any_recipient` (one entry, only when the org can send\n     anywhere): every other rule below it is redundant.\n  2. `managed_zone` (always emitted, one per Primitive-managed\n     zone): sends to any address at *.primitive.email or\n     *.email.works always succeed; no entitlement required.\n  3. `your_domain` (one per active verified outbound domain\n     owned by the org): sends to that domain are approved.\n  4. `address` (one per address that has authenticated\n     inbound mail to the org, capped at `meta.address_cap`):\n     sends to that exact address are approved.\n\nThe list is informational, not an authorization check.\n/send-mail remains the source of truth on whether an\nindividual send will succeed (it also enforces the\nfrom-address and the `send_mail` entitlement, which are\nnot recipient-scope concerns and are not represented here).\n"
      tags:
      - Sending
      responses:
        '200':
          description: Send-permission rules for the caller's org
          content:
            application/json:
              schema:
                allOf:
                - type: object
                  properties:
                    success:
                      type: boolean
                      const: true
                  required:
                  - success
                  - data
                - type: object
                  properties:
                    data:
                      type: array
                      items:
                        description: 'One recipient-scope rule describing a destination the caller

                          may send to. Discriminated on `type`. Each rule carries a

                          human-prose `description` field intended for display.


                          Rule kinds are stable within an SDK release. A response

                          containing a `type` value not enumerated in this schema

                          means the server is running a newer version than the SDK;

                          upgrade the SDK to the release that matches the server''s

                          schema. Strict-parsing SDKs (Go, Python) will raise a

                          decode error in that case rather than silently dropping

                          the unknown rule, since silent drops would let an outbound

                          agent reason from an incomplete view of its own permissions.

                          '
                        discriminator:
                          propertyName: type
                          mapping:
                            any_recipient: '#/components/schemas/SendPermissionAnyRecipient'
                            managed_zone: '#/components/schemas/SendPermissionManagedZone'
                            your_domain: '#/components/schemas/SendPermissionYourDomain'
                            address: '#/components/schemas/SendPermissionAddress'
                        oneOf:
                        - type: object
                          description: 'The caller can send to any recipient. When this rule is

                            present, every other rule in the response is redundant.

                            '
                          properties:
                            type:
                              type: string
                              enum:
                              - any_recipient
                            description:
                              type: string
                              description: Human-prose summary of the rule.
                          required:
                          - type
                          - description
                        - type: object
                          description: 'The caller can send to any address at the named

                            Primitive-managed zone. Always emitted (no entitlement

                            required) because Primitive owns the zone and every mailbox

                            belongs to a Primitive customer by construction.

                            '
                          properties:
                            type:
                              type: string
                              enum:
                              - managed_zone
                            zone:
                              type: string
                              description: 'The managed apex domain. Sends are accepted to any

                                address at the apex itself or any subdomain (e.g.

                                `alice@primitive.email` and `alice@acme.primitive.email`

                                both match the `primitive.email` zone rule).

                                '
                            description:
                              type: string
                              description: Human-prose summary of the rule.
                          required:
                          - type
                          - zone
                          - description
                        - type: object
                          description: 'The caller can send to any address at one of their own

                            verified outbound domains. Emitted once per active row in

                            the org''s `domains` table.

                            '
                          properties:
                            type:
                              type: string
                              enum:
                              - your_domain
                            domain:
                              type: string
                              description: A verified outbound domain owned by the caller's org.
                            description:
                              type: string
                              description: Human-prose summary of the rule.
                          required:
                          - type
                          - domain
                          - description
                        - type: object
                          description: 'The caller can send to a specific address that has

                            authenticated inbound mail to the org. Emitted once per row

                            in the org''s `known_send_addresses` table, capped at

                            `meta.address_cap`.

                            '
                          properties:
                            type:
                              type: string
                              enum:
                              - address
                            address:
                              type: string
                              description: The bare email address this rule grants sends to.
                            last_received_at:
                              type: string
                              format: date-time
                              description: 'Most recent inbound email from this address that

                                authenticated successfully (DMARC pass + DKIM/SPF

                                alignment). Updated on each new authenticated receipt.

                                '
                            received_count:
                              type: integer
                              description: 'Total number of authenticated inbound emails from this

                                address. Increments only when `last_received_at` advances.

                                '
                            description:
                              type: string
                              description: Human-prose summary of the rule.
                          required:
                          - type
                          - address
                          - last_received_at
                          - received_count
                          - description
                    meta:
                      type: object
                      description: 'Response metadata for /send-permissions. The `address_cap`

                        bounds the size of the `address` rule subset; orgs with more

                        than `address_cap` known addresses almost always also hold a

                        broader rule type (`any_recipient` or `your_domain`), so the

                        cap is a response-size bound rather than a meaningful

                        product limit.

                        '
                      properties:
                        address_cap:
                          type: integer
                          description: Maximum number of `address` rules included in `data`.
                        truncated:
                          type: boolean
                          description: 'True when the org has more than `address_cap` known

                            addresses and the list was truncated. False when every

                            known address is represented or when the org holds no

                            address rules at all.

                            '
                      required:
                      - address_cap
                      - truncated
                  required:
                  - data
                  - meta
          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
        '401':
          $ref: '#/components/responses/Unauthorized'
          description: Invalid or missing API key
      security:


# --- truncated at 32 KB (127 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/primitive/refs/heads/main/openapi/primitive-sending-api-openapi.yml