Primitive Demo API

Public, no-account sandbox operations that mirror authenticated endpoints with synthetic data so an agent can learn the request and response shapes before signing up.

OpenAPI Specification

primitive-demo-api-openapi.yml Raw ↑
openapi: 3.1.0
info:
  title: Primitive Account Demo 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: Demo
  description: Public, no-account sandbox operations that mirror authenticated endpoints with synthetic data so an agent can learn the request and response shapes before signing up.
paths:
  /demo/jobs:
    post:
      operationId: startDemoJob
      summary: Start a sample asynchronous job without authentication
      description: Public, no-auth demo of the asynchronous-job pattern. Starts a synthetic job and returns `202 Accepted` with a `Location` header (and `data.status_url`) pointing at the job-status resource. Poll `GET /demo/jobs/{id}` until the status is terminal. No real work runs; the status is a fixed sample.
      tags:
      - Demo
      security: []
      responses:
        '202':
          description: Job accepted. Poll the returned status URL for progress.
          headers:
            Location:
              schema:
                type: string
                format: uri
              description: URL of the job-status resource to poll.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  demo:
                    type: boolean
                    const: true
                  note:
                    type: string
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      type:
                        type: string
                      status:
                        type: string
                        enum:
                        - queued
                        - processing
                        - completed
                        - failed
                      status_url:
                        type: string
                        format: uri
                      created_at:
                        type: string
                        format: date-time
                    required:
                    - id
                    - status
                    - status_url
                required:
                - success
                - demo
                - data
        '429':
          $ref: '#/components/responses/RateLimited'
          description: Demo rate limit reached.
      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
  /demo/jobs/{id}:
    get:
      operationId: getDemoJob
      summary: Poll a sample asynchronous job without authentication
      description: Public, no-auth demo. Returns the status of a synthetic job started by `POST /demo/jobs` — the poll half of the async-job pattern. No real work runs; the status is a fixed sample.
      tags:
      - Demo
      security: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Job id returned by POST /demo/jobs.
      responses:
        '200':
          description: Current job status.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  demo:
                    type: boolean
                    const: true
                  note:
                    type: string
                  data:
                    type: object
                    properties:
                      id:
                        type: string
                      type:
                        type: string
                      status:
                        type: string
                        enum:
                        - queued
                        - processing
                        - completed
                        - failed
                      progress:
                        type: integer
                      created_at:
                        type: string
                        format: date-time
                      estimated_completion_at:
                        type: string
                        format: date-time
                    required:
                    - id
                    - status
                    - progress
                required:
                - success
                - demo
                - data
        '429':
          $ref: '#/components/responses/RateLimited'
          description: Demo rate limit reached.
  /demo/emails:
    get:
      operationId: getDemoEmails
      summary: List sample emails without authentication
      description: Public, no-auth demo. Returns synthetic email objects shaped exactly like the authenticated `GET /emails` response (same `EmailSummary` schema), so an arriving agent can read the data model before obtaining credentials. No real tenant data is exposed; every object is a fixed sample. Rate-limited per IP.
      tags:
      - Demo
      security: []
      responses:
        '200':
          description: Synthetic list of emails, same shape as the authenticated /emails response.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  demo:
                    type: boolean
                    const: true
                  note:
                    type: string
                    description: Reminder that the data is synthetic.
                  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
                required:
                - success
                - demo
                - data
        '429':
          $ref: '#/components/responses/RateLimited'
          description: Demo rate limit reached.
  /demo/emails/{id}:
    get:
      operationId: getDemoEmail
      summary: Get one sample email without authentication
      description: Public, no-auth demo. Returns a single synthetic email shaped exactly like the authenticated `GET /emails/{id}` response (`EmailDetail` schema). No real tenant data is exposed. Rate-limited per IP.
      tags:
      - Demo
      security: []
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: Any value; the demo returns a fixed sample.
      responses:
        '200':
          description: Synthetic email detail, same shape as the authenticated /emails/{id} response.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                    const: true
                  demo:
                    type: boolean
                    const: true
                  note:
                    type: string
                    description: Reminder that the data is synthetic.
                  data:
                    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
                      sender:
                        type: string
                        description: 'SMTP envelope sender (return-path) the inbound mail server

                          accepted. Same value as `smtp_mail_from`; both fields exist

                          so protocol-aware tooling can use whichever name it expects.


                          For most legitimate mail this equals `from_email`; for

                          mailing lists, bounce handlers, and forwarders it is

                          typically the bounce-handling address rather than the

                          human-visible sender.


                          **For the canonical "who sent this email" value, use

                          `from_email`.**

                          '
                      recipient:
                        type: string
                      subject:
                        type:
                        - string
                        - 'null'
                      body_text:
                        type:
                        - string
                        - 'null'
                        description: Plain-text body parsed from the inbound MIME, matching the `email.parsed.body_text` field on the webhook payload. Null when the message had no text part or parsing failed.
                      body_html:
                        type:
                        - string
                        - 'null'
                        description: HTML body parsed from the inbound MIME, matching the `email.parsed.body_html` field on the webhook payload. Null when the message had no HTML part or parsing failed.
                      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
                      domain:
                        type: string
                      spam_score:
                        type:
                        - number
                        - 'null'
                      raw_size_bytes:
                        type:
                        - integer
                        - 'null'
                      raw_sha256:
                        type:
                        - string
                        - 'null'
                      created_at:
                        type: string
                        format: date-time
                      received_at:
                        type: string
                        format: date-time
                      rejection_reason:
                        type:
                        - string
                        - '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
                      webhook_last_attempt_at:
                        type:
                        - string
                        - 'null'
                        format: date-time
                      webhook_last_status_code:
                        type:
                        - integer
                        - 'null'
                      webhook_last_error:
                        type:
                        - string
                        - 'null'
                      webhook_fired_at:
                        type:
                        - string
                        - 'null'
                        format: date-time
                      smtp_helo:
                        type:
                        - string
                        - 'null'
                      smtp_mail_from:
                        type:
                        - string
                        - 'null'
                        description: 'SMTP envelope MAIL FROM (return-path), as accepted by the

                          inbound mail server. Same value as `sender`; both fields

                          exist so protocol-aware tooling can use whichever name it

                          expects.


                          For the canonical "who sent this email" value (display name

                          stripped, From-header preferred), use `from_email`.

                          '
                      smtp_rcpt_to:
                        type:
                        - array
                        - 'null'
                        items:
                          type: string
                      from_header:
                        type:
                        - string
                        - 'null'
                        description: 'Raw `From:` header from the message body, including any

                          display name (e.g. `"Alice Example" <alice@example.com>`).

                          Use this when you need the display name for rendering.


                          For the bare email address (display name stripped), use

                          `from_email`.

                          '
                      content_discarded_at:
                        type:
                        - string
                        - 'null'
                        format: date-time
                      content_discarded_by_delivery_id:
                        type:
                        - string
                        - 'null'
                      from_email:
                        type: string
                        description: 'Bare email address parsed from the `From:` header, with

                          display name stripped (e.g. `alice@example.com`). Falls

                          back to `sender` (the SMTP envelope MAIL FROM) when the

                          `From:` header cannot be parsed.


                          **This is the canonical "who sent this email" field for

                          most use cases**, including comparing against allowlists,

                          routing replies, or displaying the sender to a user. Use

                          `from_header` when you specifically need the display name,

                          or `sender`/`smtp_mail_from` when you need the SMTP

                          envelope value (e.g. to follow a bounce).

                          '
                      to_email:
                        type: string
                        description: Parsed to address (same as recipient)
                      from_known_address:
                        type: boolean
                        description: 'True when the inbound''s sender address has a matching grant

                          in the org''s known-send-addresses list. Advisory: a true

                          value does not by itself guarantee that a reply will be

                          accepted by send-mail''s gates; the per-send check at send

                          time remains authoritative.

                          '
                      replies:
                        type: array
                        description: 'Sent emails recorded as replies to this inbound, in send

                          order (ascending). Populated when a customer''s send-mail

                          request carries an `in_reply_to` Message-ID that matches

                          this inbound''s `message_id` in the same org. Includes

                          attempts that were gate-denied, so the array reflects every

                          recorded reply attempt regardless of outcome.

                          '
                        items:
                          type: object
                          properties:
                            id:
                              type: string
                              format: uuid
                              description: Sent-email row 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
                            to_address:
                              type: string
                              description: Recipient address as recorded on the sent_emails row.
                            subject:
                              type:
                              - str

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