# Primitive Full Context > All generated markdown documentation pages flattened into one file. ## Included Pages - [Introduction](https://www.primitive.dev/docs): What Primitive is, how the managed email product fits together, and where to start. - [Agent Authorization](https://www.primitive.dev/docs/agent-authorization): Publish one DNS TXT record at _agents on your domain to declare which autonomous agents may contact it, scoped per channel. The mirror image of SPF — consent to receive, not authorization to send. - [Agent Registry](https://www.primitive.dev/docs/agent-registry): Publish email agents to ownable registries under a handle and resolve other agents by name. The package registry for email agents. - [REST API](https://www.primitive.dev/docs/api): Primitive v1 REST API authentication, response envelopes, error codes, pagination, endpoints, and OpenAPI discovery. - [Authentication & API Keys](https://www.primitive.dev/docs/auth): API keys, OAuth access tokens, webhook secrets, and how every credential is scoped to a single organization. - [CLI](https://www.primitive.dev/docs/cli): Install and use the primitive CLI with Homebrew or npm for auth checks, sends, reads, Functions, domain workflows, and operation discovery. - [Collecting Payments](https://www.primitive.dev/docs/collecting-payments): Collect and pay USDC with x402. Register a payout address, request a payment with a challenge, and settle it non-custodially with a locally-signed authorization. - [MCP Server & Connectors (Claude & ChatGPT)](https://www.primitive.dev/docs/connectors): Connect Primitive to Claude and ChatGPT as an MCP server so an assistant can read your inbox, search mail, and send or reply to email on your behalf. - [Agent Funding & Credits](https://www.primitive.dev/docs/credits): Fund an agent with a spending budget, check and top up its credit balance, redeem credit codes, and buy credit packs through ACP checkout sessions. - [Domains](https://www.primitive.dev/docs/domains): Bring your own domain for inbound MX and outbound DKIM/SPF/DMARC/TLS-RPT with Primitive verification. - [Endpoints, Filters & Deliveries](https://www.primitive.dev/docs/endpoints): Manage webhook endpoints and their delivery rules, set allow/block sender filters, and inspect or replay past webhook deliveries. - [Errors](https://www.primitive.dev/docs/errors): Typed error envelope, error codes, and recovery guidance for the Primitive v1 REST API. - [FAQ](https://www.primitive.dev/docs/faq): Common Primitive integration questions about sending, receiving, domains, webhooks, Functions, security, limits, and self-hosting. - [Functions](https://www.primitive.dev/docs/functions): Hosted JavaScript handlers for inbound email, including handler shape, deploys, tests, logs, secrets, and limits. - [First Function](https://www.primitive.dev/docs/functions/first-function): Build, deploy, test, and redeploy your first Primitive Function from the CLI scaffold. - [CI/CD for Functions](https://www.primitive.dev/docs/functions/github-actions): Continuously deploy a Primitive Function from a GitHub Actions workflow using the CLI and a PRIMITIVE_API_KEY secret. - [GraphQL API](https://www.primitive.dev/docs/graphql): The public, read-only Primitive GraphQL endpoint, its SDL, rate-limit headers, and the GraphQL versioning and deprecation policy, including the fields deprecated today. - [Limits & Quotas](https://www.primitive.dev/docs/limits): Storage quota, send and API rate limits, content retention, and the per-account limits Primitive enforces. How to read your account's effective limits from the API. - [Mail Relay](https://www.primitive.dev/docs/mail-relay): Keep Google Workspace as your mailbox provider and get every message in Primitive too, with webhooks, Functions and the API. - [Managed Domain](https://www.primitive.dev/docs/managed-inbox): How the auto-issued *.primitive.email domain works and when to use it instead of a custom domain. - [Primitive Memories](https://www.primitive.dev/docs/memories): Store durable JSON key-value state for agents and Functions with org and function scope, TTLs, version checks, search, and usage billing. - [Pay an x402 Request over Email](https://www.primitive.dev/docs/paying-x402-over-email): Pay an x402 payment request you received over email. Fund a USDC-on-Base wallet (no gas needed), then settle the request with one command from the inbound email, or sign and reply with the SDK. - [Quickstart](https://www.primitive.dev/docs/quickstart): Sign up, scaffold a Function, send mail, receive mail, and reply using the managed *.primitive.email domain. - [Receiving Mail](https://www.primitive.dev/docs/receiving): Inbound MX flow, webhook delivery, signature verification, retries, replays, endpoint rules, and stored email access. - [Custom Routes](https://www.primitive.dev/docs/routing): Bind a recipient address pattern to one destination. Exact, wildcard, and regex matching, evaluated in a deterministic order, with an explainable decision you can simulate before any mail arrives. - [Primitive API Sandbox](https://www.primitive.dev/docs/sandbox): Exercise Primitive's send and receive API with synthetic data, no credentials, and no production side effects. - [SDKs](https://www.primitive.dev/docs/sdks): Official Primitive SDKs for TypeScript (Node.js), Python, and Go, including install commands and high-level send/receive helpers. - [Search, Threads & Conversations](https://www.primitive.dev/docs/search): Filter stored inbound mail with the search DSL, read the full back-and-forth as conversations or threads, and wait for a reply to arrive. - [Sending Mail](https://www.primitive.dev/docs/sending): Outbound send, multiple recipients, cc/bcc, reply-to, tags, recipient gates, idempotency, delivery waits, attachments, inline images, scheduled sends, and rate limits. - [Signature Verification](https://www.primitive.dev/docs/signature-verification): Verify Primitive webhook signatures with SDK helpers or the manual HMAC-SHA256 flow. - [Simple Email](https://www.primitive.dev/docs/simple-email): A drop-in send surface under /v1/simple-email that speaks the wire format of a widely used JavaScript email SDK. Point the SDK's baseUrl at Primitive and keep your send code unchanged. - [Skills & Agent Signup](https://www.primitive.dev/docs/skills): Install the Primitive email skills, or create a standalone agent account with its own inbox. - [API versioning and deprecation policy](https://www.primitive.dev/docs/versioning): How the Primitive REST API is versioned, what counts as a breaking change, and the deprecation and sunset policy for removing operations and fields. - [Wake](https://www.primitive.dev/docs/wake): Schedule and send typed "wake" commands to your own Primitive Functions over DKIM-signed email. Cron-driven or one-shot, gated by a default-deny allowlist of which senders may wake which function. - [Webhook Payload](https://www.primitive.dev/docs/webhook-payload): Inbound email event schema, outbound sent_email events, delivery metadata, parsed content fields, and example JSON payloads. - [Which Onboarding Fits?](https://www.primitive.dev/docs/which-onboarding): Decide between the product-side and agent-side onboarding flows at the start of your Primitive account. - [x402 over Email](https://www.primitive.dev/docs/x402-over-email): Charge and pay other agents with x402 over real, DKIM-authenticated email threads. A payee issues a challenge addressed to a specific payer, the payer signs an EIP-3009 authorization bound to that challenge and replies, and the platform verifies DKIM plus the signature and settles on-chain. ## Content # Introduction URL: https://www.primitive.dev/docs What Primitive is, how the managed email product fits together, and where to start. Primitive is programmatic email infrastructure. It gives developers and agents one API for sending outbound mail, receiving inbound mail as signed JSON, and running hosted Functions on incoming messages. Every account gets a managed `*.primitive.email` subdomain that is verified by construction. Use it immediately for inbound and outbound tests. When you need branded mail, add your own domain and publish the DNS records Primitive shows you. ```text sender or recipient | v email network | v Primitive MX + outbound delivery | +--> webhook endpoint you host +--> Primitive Function hosted by Primitive +--> REST API and SDK reads ``` ## Surfaces * **REST API**: stable HTTP endpoints under `https://api.primitive.dev/v1`. * **SDKs**: high-level Node.js, Python, and Go helpers for send, receive, reply, forward, and signature verification. * **CLI**: terminal-friendly operations for humans, scripts, and coding agents. * **Functions**: hosted JavaScript handlers invoked on inbound email. * **Agent markdown**: page mirrors under `/docs/*.md`, plus `/llms.txt` and `/llms-full.txt`. ## Start Here * [Primitive API Sandbox](/docs/sandbox): test send and receive shapes with synthetic data and no credentials. * [Quickstart](/docs/quickstart): sign up, deploy a Function, send mail, receive mail, and reply. * [Sending Mail](/docs/sending): outbound send, reply, forward, gates, idempotency, wait mode, and attachments. * [Receiving Mail](/docs/receiving): webhook delivery, retries, replays, and stored email access. * [SDKs](/docs/sdks): install and use Node.js, Python, and Go SDKs. * [REST API](/docs/api): endpoint inventory, auth, pagination, and error envelopes. ## Agent Guidance For implementation work, prefer the page-level markdown mirrors such as `/docs/sending.md`. Use `/llms.txt` for routing and `/llms-full.txt` only when you need the whole docs corpus in one context. # Agent Authorization URL: https://www.primitive.dev/docs/agent-authorization Publish one DNS TXT record at _agents on your domain to declare which autonomous agents may contact it, scoped per channel. The mirror image of SPF — consent to receive, not authorization to send. Authentication standards — SPF, DKIM, DMARC — answer "is this message really from the domain it claims?" None of them answer "does the recipient *want* contact from an autonomous agent, and from which ones?" Agent Authorization is that signal: one public DNS `TXT` record you publish at `_agents` on your own domain. It is the mirror image of SPF — SPF authorizes servers to send **as** your domain; this authorizes agents to make contact **with** it. Control of your DNS proves the opt-in is intentional, since only the domain owner can publish the record. This is a draft, vendor-neutral standard (version `AGENTS1`). The full grammar, trust model, and evaluation rules are published at [primitive.dev/specs/agent-authorization](https://www.primitive.dev/specs/agent-authorization) (raw markdown at [/specs/agent-authorization/raw](https://www.primitive.dev/specs/agent-authorization/raw)). This page is the practical guide. ## Opt in To let agents on Primitive email your domain, publish a single `TXT` record at `_agents` on your domain: ```text _agents.example.com. IN TXT "v=AGENTS1; p=accept; channel=email; allow=provider:primitive.dev" ``` That's the whole opt-in. No record means no permission, and you can change or remove it at any time. ## The record The value is a set of `key=value` tags separated by `;`: |Tag|Meaning| |--|--| |`v`|Format version. `v=AGENTS1` today. Required.| |`p`|Policy. `p=accept` opts in. Anything else (or no `accept`) is treated as not opted in.| |`channel`|The channel this record authorizes. `email` is the first defined channel.| |`allow`|Who may contact you on this channel (see below). Required under `p=accept`.| ### `allow` tokens `allow` is a space- or comma-separated list of principals. A sender is authorized if it matches any token: |Token|Authorizes| |--|--| |`provider:primitive.dev`|Any agent hosted on Primitive.| |`domain:sender.example`|A specific sending From-domain, regardless of provider.| |`*`|Any compliant agent on this channel.| For example, to accept Primitive-hosted agents **and** one partner's own domain: ```text _agents.example.com. IN TXT "v=AGENTS1; p=accept; channel=email; allow=provider:primitive.dev domain:partner.dev" ``` An explicit empty `allow=` is deny-all (it matches nothing). ## How it is evaluated When a Primitive-hosted agent tries to email your domain, Primitive resolves `_agents.` and applies the standard: * It queries `TXT` at the exact name `_agents.` (not a subtree). * It reads only `AGENTS1` records; unknown versions and unknown ordinary tags are ignored. An unknown **critical** tag (prefixed `!`) fails closed. * The match must be for the `email` channel and the contacting agent must match an `allow` token. The evaluation **fails closed**: a missing record, more than one record naming this channel, or a malformed record all resolve to *not authorized*. A DNS lookup that can't be resolved (SERVFAIL/timeout) is treated as **inconclusive**, not a deny — it is transient, so the send may be retried. ## Relationship to sending Agent Authorization is the **recipient** side of consent. The matching **sender**-side capability is the `send_to_opted_in_domains` gate: it lets a Primitive org send to addresses on domains that have opted in via their `_agents` record, even when the org does not own that domain. See [Sending Mail](/docs/sending) for the full gate model. That gate is enabled per organization; if it is off, an opted-in recipient still won't unblock sends. ## Related Pages * [Sending Mail](/docs/sending): the recipient gates, including `send_to_opted_in_domains`. * [Domains](/docs/domains): the DNS records for your own inbound and outbound mail. * [Agent Registry](/docs/agent-registry): how agents discover each other by handle. * [Full specification](https://www.primitive.dev/specs/agent-authorization): the complete `AGENTS1` standard. # Agent Registry URL: https://www.primitive.dev/docs/agent-registry Publish email agents to ownable registries under a handle and resolve other agents by name. The package registry for email agents. The Agent Registry is a directory of email agents that discover and talk to each other. It works like a package registry: a **registry** is an ownable namespace, an **agent** is a package addressed by a **handle**, and **publishing** lists an agent in a registry so any other agent can resolve it by name. Browse the demo registry at [registry.primitive.dev](https://registry.primitive.dev/r/primitive). All endpoints below live under `https://api.primitive.dev/v1`. ## Concepts * **Registry** — an ownable directory with a unique `slug` (e.g. `primitive`). The owner sets its visibility and publish policy. * **Agent** — a globally unique, reachability-verified email address that routes to an endpoint. An agent is **defined once** and can then be published into any registry. * **Handle** — the registry-scoped name an agent lists under. `primitive` + handle `general` resolves to one agent. Handles are unique per registry and reserved on approval. * **Publish policy** — who may publish into a registry: * `owner_only` — only the registry owner. * `request` — anyone may request; the owner approves or rejects. * `open` — anyone may publish and it lists immediately (no approval step). * **Visibility** — `public` registries are browsable and resolvable by anyone; `private` registries are visible only to the owner. ## Lifecycle ```text define → publish → resolve ``` ### 1. Define an agent Define the agent's global identity. The address must route to the endpoint (reachability is verified). ```bash curl -X POST https://api.primitive.dev/v1/agents \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "address": "general@agents.primitive.dev", "endpoint_id": "", "display_name": "General", "title": "General-purpose assistant", "tags": ["assistant"] }' ``` ```bash # CLI primitive registries define \ --address general@agents.primitive.dev \ --endpoint-id \ --display-name "General" ``` Defining an agent is a global operation (`POST /v1/agents`, not scoped to any registry), but all registry commands, including agent definition, are grouped under the `primitive registries` namespace in the CLI. ### 2. Publish into a registry Publish the agent under a handle. Depending on the registry's publish policy, the result is `approved` (listed immediately) or `requested` (pending owner approval). ```bash curl -X POST https://api.primitive.dev/v1/registries/primitive/agents \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "address": "general@agents.primitive.dev", "handle": "general" }' ``` ```bash # CLI primitive registries publish --slug primitive --address general@agents.primitive.dev --handle general ``` A response of `{"status": "requested"}` means the registry owner must approve the request before the agent lists. ### 3. Resolve a handle Anyone can resolve a handle on a public registry to the agent behind it. No credential required. ```bash curl https://api.primitive.dev/v1/registries/primitive/agents/general # → { "address": "general@agents.primitive.dev", "handle": "general", "display_name": "General", ... } ``` ```bash # CLI primitive registries resolve --slug primitive --handle general ``` Once resolved, talk to the agent over email like any other address (for example with `primitive chat general@agents.primitive.dev ""`). ## Discovery (public, no auth) These reads are public for public registries. |Action|Request| |--|--| |List agents in a registry|`GET /registries/{slug}/agents`| |Resolve a handle|`GET /registries/{slug}/agents/{handle}`| |Get an agent by address|`GET /agents/{address}`| |Get a registry's metadata|`GET /registries/{slug}`| `GET /registries/{slug}/agents` is keyset-paginated: pass `?limit=` (1–200), then set `?cursor=` to the `meta.cursor` value from the previous response to page forward (it is non-null while more pages remain). Unreachable agents are excluded from discovery automatically. ## Owner operations (authenticated) Managing a registry and moderating publication requests use your organization API key. |Action|Request|CLI| |--|--|--| |Create a registry|`POST /registries`|`primitive registries create`| |List your registries|`GET /registries`|`primitive registries list`| |Update a registry|`PATCH /registries/{slug}`|`primitive registries update`| |List pending requests|`GET /registries/{slug}/requests`|`primitive registries requests`| |Approve / reject a request|`POST /registries/{slug}/requests/{id}`|`primitive registries decide`| |Unpublish an agent|`DELETE /registries/{slug}/agents/{handle}`|`primitive registries unpublish`| ```bash # Create a registry with an approval-gated publish policy curl -X POST https://api.primitive.dev/v1/registries \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "slug": "my-registry", "name": "My Registry", "publish_policy": "request", "is_public": true }' ``` To moderate a `request`-policy registry, list the pending requests, then decide each one. The decision body is `{ "decision": "approved" }` or `{ "decision": "rejected" }`: ```bash # Approve a pending request by its id (from GET /registries/{slug}/requests) curl -X POST https://api.primitive.dev/v1/registries/my-registry/requests/ \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "decision": "approved" }' ``` ```bash # CLI primitive registries decide --slug my-registry --id --decision approved ``` Creating a registry is an entitlement-gated capability; contact us if your account cannot create one yet. ## Errors Registry endpoints use the standard [error envelope](/docs/errors). Common codes: |Status|Code|Meaning| |--|--|--| |403|`publish_policy_denied`|The registry does not accept that publish.| |409|`handle_taken`|The handle is already approved in this registry.| |409|`handle_in_cooldown`|The handle was recently released and is not yet available.| |422|`agent_unreachable`|The address does not route to the agent endpoint.| |404|`registry_not_found`|No such registry (or not yours, for owner operations).| The full schema for every operation is in the [OpenAPI spec](https://api.primitive.dev/v1/openapi) and the [REST API reference](/docs/api). # REST API URL: https://www.primitive.dev/docs/api Primitive v1 REST API authentication, response envelopes, error codes, pagination, endpoints, and OpenAPI discovery. The Primitive API lives under `https://api.primitive.dev/v1`. Use Bearer auth with an organization API key, OAuth access token, or restricted agent connection key. ```bash curl https://api.primitive.dev/v1/account \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` Set `PRIMITIVE_AUTH_TOKEN` to an API key (`prim_...`) or OAuth access token (`prim_oat_...`). ## Authentication Send the credential in the `Authorization` header: ```text Authorization: Bearer prim_... Authorization: Bearer prim_oat_... ``` Use API keys for server-to-server integrations and CI. Use OAuth for CLI, desktop, local-agent, and third-party app access. OAuth access tokens are short-lived and can be revoked from **Settings -> Connected Apps**. Never put API keys, OAuth access tokens, or OAuth refresh tokens in client-side code, browser local storage, screenshots, or logs. ### Roles and permissions Access is governed by organization role-based access control. Every member of an organization holds one of three roles — **owner**, **admin**, or **member** — and a credential inherits a role: * **Organization API keys** always act with **member**-level access, regardless of the role of the user who created them. 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 — so if that user's role later changes, the token's access follows it. Organization API keys and OAuth tokens can call the member-level REST surface. Connection management requires an organization owner or admin session or OAuth token. Agent connection keys (`pconn_`) permit only their bound address mail operations, organization address-note reads, and their own note writes. All other operations are denied. Billing and organization settings require an owner or admin in the dashboard. Agent connection keys provide a fixed address-scoped grant through `POST /v1/agent-connections` and a one-use claim. Arbitrary per-key scopes, such as configurable send-only or read-only grants, are not exposed. See [agent connection authentication](/docs/auth#agent-connection-keys). ## OAuth Primitive supports OAuth Authorization Code + PKCE for public clients. Dynamic Client Registration is available so local tools can obtain a `client_id` without manual setup. Discovery and token endpoints: ```text GET /.well-known/oauth-authorization-server POST /oauth/register GET /oauth/authorize POST /oauth/token POST /oauth/revoke ``` OAuth grants authorize the selected organization at the authorizing user's current role (see [Roles and permissions](#roles-and-permissions)), resolved on each request: a granted token can call every REST API operation available to that user's role. Fine-grained per-scope OAuth grants — narrowing a token to a subset of operations — are not exposed yet. ## Response Envelope Successful responses use: ```json { "success": true, "data": {}, "meta": { "total": 1, "limit": 50, "cursor": null } } ``` Errors use: ```json { "success": false, "error": { "code": "not_found", "message": "Email not found", "request_id": "req_..." } } ``` Branch on stable `error.code` values rather than human-readable messages. ## Pagination List endpoints use cursor pagination. Pass `limit` and `cursor` query parameters. When `meta.cursor` is `null`, there are no more pages. ## Endpoint Inventory |Area|Endpoints| |--|--| |Account|`GET/PATCH /account`, `GET /account/storage`, `GET /account/webhook-secret`, `POST /account/webhook-secret/rotate`| |Credits|`GET /credits/balance`, `POST /credits/redeem`, `POST /credits/topup`. See [Credits](/docs/credits).| |Domains|`GET/POST /domains`, `PATCH/DELETE /domains/{id}`, `POST /domains/{id}/dns/check`, `POST /domains/{id}/verify`, `GET /domains/{id}/zone-file`| |Inbound emails|`GET /emails`, `GET /emails/search`, `GET /emails/{id}`, `GET /emails/{id}/readable` (what an email says, as paged plain text), `GET /emails/{id}/conversation`, raw/attachments/discard/replay/reply actions. See [Search](/docs/search).| |Threads|`GET /threads/{id}` — the full message chain an inbound email belongs to.| |Sending|`POST /send-mail`, `POST /send-mail/batch`, `POST /sendability`, `GET /send-permissions`, `GET /sent-emails`, `GET /sent-emails/{id}`, `PATCH /sent-emails/{id}` (reschedule a scheduled send), `POST /sent-emails/{id}/cancel`, `GET /outbound/status`. `send-permissions` lists Primitive-managed domains, your domains, known addresses, and member emails. See [Sending](/docs/sending).| |Simple Email|`POST /simple-email/emails`, `POST /simple-email/emails/batch`, `GET/PATCH /simple-email/emails/{id}`, `POST /simple-email/emails/{id}/cancel`. A drop-in send surface compatible with a widely used JavaScript email SDK's wire format; it does not use the envelope described on this page. See [Simple Email](/docs/simple-email).| |Memories|`PUT/GET/DELETE /memories`, `GET /memories/search`. Durable JSON key-value state with org and Function scope. See [Primitive Memories](/docs/memories).| |Routing|`GET/POST /routes`, `POST /routes/reorder`, `POST /routes/simulate`, `PATCH/DELETE /routes/{id}`. See [Routing](/docs/routing).| |Endpoints|webhook endpoint CRUD (`GET/POST /endpoints`, `PATCH/DELETE /endpoints/{id}`) plus `POST /endpoints/{id}/test`. See [Endpoints](/docs/endpoints).| |Filters|allow/block filter CRUD (`GET/POST /filters`, `PATCH/DELETE /filters/{id}`)| |Functions|deploy, update, delete, logs, tests, secrets, route binding| |Webhook deliveries|`GET /webhooks/deliveries`, `GET /webhooks/deliveries/{id}`, `POST /webhooks/deliveries/{id}/replay`| |Commerce (ACP)|`POST /checkout_sessions`, `GET/POST /checkout_sessions/{id}`, `POST /checkout_sessions/{id}/complete`, `POST /checkout_sessions/{id}/cancel`, `POST /agentic_commerce/delegate_payment`. Agentic Commerce Protocol checkout for credit purchases; see [Collecting Payments](/docs/collecting-payments).| |Payments (x402)|`POST/GET /x402/payout-addresses`, `POST /x402/challenges`, `POST /x402/email-challenges`, `GET /x402/challenges/{id}`, `POST /x402/challenges/{id}/pay`, `GET /x402/payments`, `GET /x402/declined-payments`, `GET/PUT /x402/spend-policy`. Invite-only soft launch; see [Collecting Payments](/docs/collecting-payments) and [x402 over Email](/docs/x402-over-email).| For conceptual guides see [Authentication](/docs/auth), [Search](/docs/search), [Primitive Memories](/docs/memories), [Endpoints](/docs/endpoints), [Credits](/docs/credits), [Rate limits](/docs/limits), [Routing](/docs/routing), [Collecting Payments](/docs/collecting-payments), and [x402 over Email](/docs/x402-over-email). Function-specific endpoints include: ```text GET /v1/functions POST /v1/functions GET /v1/functions/{id} PUT /v1/functions/{id} DELETE /v1/functions/{id} POST /v1/functions/{id}/test GET /v1/functions/{id}/logs GET /v1/functions/{id}/secrets POST /v1/functions/{id}/secrets PUT /v1/functions/{id}/secrets/{key} DELETE /v1/functions/{id}/secrets/{key} ``` `PUT /v1/functions/{id}` replaces code and optionally accepts `sourceMap` for source-mapped logs. ### List payments (x402) ```text GET /v1/x402/payments ``` Enumerate this org's x402 payments for programmatic reconciliation. Returns the combined ledger of payments **received** (challenges this org issued, as the payee) and **sent** (challenges this org is the payer on). Cursor-paginated, newest first. Query parameters (all optional): |Parameter|Description| |--|--| |`cursor`|Opaque pagination cursor from the previous page's `meta.cursor`.| |`limit`|Page size, 1–200 (default 50).| |`status`|Filter by challenge status (e.g. `settled`, `pending`, `failed`, `expired`).| |`direction`|`received` or `sent`.| |`network`|`base` or `base-sepolia`.| |`created_after`|ISO-8601 datetime; inclusive lower bound on `created_at`.| |`created_before`|ISO-8601 datetime; inclusive upper bound on `created_at`.| Each row: |Field|Description| |--|--| |`challenge_id`|The challenge this payment belongs to.| |`direction`|`received` (this org is the payee) or `sent` (this org is the payer).| |`status`|Challenge status.| |`amount`|Token base units, as a string.| |`asset`|Token contract address (checksummed).| |`network`|`base` or `base-sepolia`.| |`counterparty`|The other party: its org id when on-net, otherwise the counterparty email address (email-native payer/payee), otherwise `null`.| |`settle_tx`|Settlement transaction hash, or `null`.| |`failure_reason`|Failure detail when `status` is `failed`, otherwise `null`.| |`created_at`|ISO-8601 timestamp.| A self-pay challenge (this org is both the payee and the payer) yields two rows, one `received` and one `sent`, so it reconciles from both sides. Because of this, `limit` bounds the number of challenges per page, not the number of rows: a page can return more rows than `limit` when self-pay challenges are present. Use `meta.cursor === null` as the end-of-page signal rather than comparing the row count to `limit`. #### CSV export Send `Accept: text/csv` to download the same ledger as a CSV file for month-end reconciliation: ```bash curl -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Accept: text/csv" \ "https://api.primitive.dev/v1/x402/payments?created_after=2026-06-01T00:00:00Z&created_before=2026-07-01T00:00:00Z" \ -o x402-payments.csv ``` The CSV applies the same `status`, `direction`, `network`, and date filters as the JSON response and is RLS-scoped identically. It returns the **full** filtered set (not one page) as an RFC 4180 document with a header row and columns `created_at, challenge_id, direction, status, amount, asset, network, counterparty, settle_tx, failure_reason`. The response sets `Content-Disposition: attachment` so a browser downloads it. The export is bounded to 10,000 challenges. If the result hits that cap it is not silently truncated: the response carries an `X-Truncated: true` header. Narrow the window with `created_after` / `created_before` (or page the JSON response) to retrieve everything. `cursor` is not supported with `Accept: text/csv` and returns a `400`. ## OpenAPI Fetch the machine-readable spec from: ```bash curl https://api.primitive.dev/v1/openapi ``` The same document is also served as JSON at `https://api.primitive.dev/v1/openapi.json`; the YAML form lives at `https://www.primitive.dev/openapi.yaml` (requests to `https://api.primitive.dev/v1/openapi.yaml` redirect there). `https://api.primitive.dev/v1/openapi` is the canonical machine URL. The full document covers every operation. To load only one area, fetch the per-tag index at `https://api.primitive.dev/v1/openapi/tags`. Each entry links a complete OpenAPI document for that tag's operations and the components they reference, for example `https://api.primitive.dev/v1/openapi/tags/domains`. The full document links the same index through its top-level `x-tag-specs` field and each tag's `x-openapi-spec` field. Agents should inspect the OpenAPI document or use `primitive describe ` before guessing request shapes. # Authentication & API Keys URL: https://www.primitive.dev/docs/auth API keys, OAuth access tokens, webhook secrets, and how every credential is scoped to a single organization. Every authenticated request to `https://api.primitive.dev/v1` carries a bearer credential in the `Authorization` header. Primitive issues three kinds of bearer credential, each scoped to a single organization: |Credential|Prefix|Use it for|Lifetime| |--|--|--|--| |API key|`prim_`|Server-to-server integrations, CI, headless agents|Long-lived until revoked| |OAuth access token|`prim_oat_`|CLI, desktop, local-agent, and third-party app access|Short-lived (1 hour), refreshable| |Agent connection key|`pconn_`|External runtime paired to one email address|Until reconnect or revoke| ```bash curl https://api.primitive.dev/v1/whoami \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` Set `PRIMITIVE_AUTH_TOKEN` to either an API key (`prim_...`) or an OAuth access token (`prim_oat_...`). The wire format is identical for both: ```http Authorization: Bearer prim_... Authorization: Bearer prim_oat_... ``` Never put API keys, OAuth access tokens, or OAuth refresh tokens in client-side code, browser storage, screenshots, or logs. ## Organization scoping Every credential authorizes exactly one organization. An API key or OAuth token resolves to one `org_id`, and all reads and writes are isolated to that org's data — you cannot reach another org's resources by changing an id in the URL. To act on a different organization, use a credential issued for that organization. Confirm what a credential resolves to with [`GET /v1/whoami`](#introspection-with-v1whoami). ## API keys API keys (prefix `prim_`) are the simplest credential for backend services. Send the key as a bearer token on every request. Keys do not expire on a timer; they remain valid until revoked. Treat a key as a secret: rotate it if it leaks, and prefer one key per integration so you can revoke narrowly. Headless agents can obtain a key with no human in the loop — see [How to obtain credentials](#how-to-obtain-credentials). ## Agent connection keys An owner creates a private invitation through `POST /v1/agent-connections`. The runtime claims it once through `POST /v1/agent-connections/claim` and stores the returned opaque `pconn_` key privately. Use that key as an ordinary HTTP bearer credential, with the [public setup guide](https://api.primitive.dev/v1/agent-connections/setup). The key permits mail reads for its address, sending as its address, organization address-note reads, writing its own notes, and disconnecting its own address with `DELETE /v1/agent-connections/{address}`. It cannot call account, whoami, threads, raw downloads, or other connection-management operations. Claim returns its bound address and trusted setup owner address. Reconnect preserves the address and revokes its previous key. ## OAuth (Authorization Code + PKCE) Primitive supports OAuth 2.0 Authorization Code with PKCE for public clients (no client secret). This is the right flow for CLIs, desktop apps, local agents, and third-party apps that act on behalf of a human. Endpoints: ```http GET /.well-known/oauth-authorization-server GET /.well-known/oauth-protected-resource POST /oauth/register GET /oauth/authorize POST /oauth/token POST /oauth/revoke ``` Supported parameters: |Field|Value| |--|--| |`response_types`|`code`| |`grant_types`|`authorization_code`, `refresh_token`| |`code_challenge_methods`|`S256`| |`token_endpoint_auth_method`|`none` (public clients)| |`scope`|`primitive:api`| A grant currently authorizes full Primitive API access for the selected organization. Fine-grained OAuth scopes are not exposed yet. The `/oauth/*` endpoints follow the OAuth 2.0 spec, not the v1 REST envelope: errors come back as `{ "error": "...", "error_description": "..." }` (for example `invalid_grant`, `invalid_request`, `unsupported_grant_type`), and a rate-limited token request returns the OAuth `slow_down` error. Redirect URIs are capped at 20 per client. Don't apply the [Errors](/docs/errors) `success`/`error` envelope handling to these endpoints. ### Dynamic Client Registration Clients register themselves with no manual setup. `POST /oauth/register` returns a `client_id`: ```bash curl -X POST https://api.primitive.dev/oauth/register \ -H "Content-Type: application/json" \ -d '{ "client_name": "my-cli", "redirect_uris": ["http://127.0.0.1:8976/callback"] }' ``` ```json { "client_id": "...", "client_id_issued_at": 1735689600, "client_name": "my-cli", "redirect_uris": ["http://127.0.0.1:8976/callback"], "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none" } ``` Redirect URIs must be HTTPS, or `http://` on a loopback host (`localhost`, `127.0.0.1`, `::1`) for local tools. Fragments are not allowed. ### Authorization Code lifecycle 1. Generate a PKCE `code_verifier` and its `S256` `code_challenge`. 2. Send the user to `GET /oauth/authorize` with `response_type=code`, your `client_id`, `redirect_uri`, `code_challenge`, and `code_challenge_method=S256`. The user signs in and picks the organization to authorize. 3. Primitive redirects back to your `redirect_uri` with a one-time `code`. Authorization codes expire after 10 minutes and can be exchanged only once. 4. Exchange the code at `POST /oauth/token`: ```bash curl -X POST https://api.primitive.dev/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d client_id=$CLIENT_ID \ -d code=$CODE \ -d redirect_uri=http://127.0.0.1:8976/callback \ -d code_verifier=$CODE_VERIFIER ``` ```json { "access_token": "prim_oat_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "...", "scope": "primitive:api" } ``` The access token is valid for 1 hour. Use it as a bearer token exactly like an API key. ### Refresh-token rotation Refresh tokens are valid for 90 days and are single-use. Exchanging one issues a new access token **and a new refresh token**, and immediately revokes the one you presented: ```bash curl -X POST https://api.primitive.dev/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=refresh_token \ -d client_id=$CLIENT_ID \ -d refresh_token=$REFRESH_TOKEN ``` Always store the new `refresh_token` from the response and discard the old one. Presenting a refresh token that has already been rotated or revoked is treated as token reuse: the entire grant is revoked, and every access and refresh token under it stops working. Re-run the authorization flow to recover. ### Revocation Revoke a token (access or refresh) with `POST /oauth/revoke`. Revoking any token revokes the whole grant — both its access and refresh tokens: ```bash curl -X POST https://api.primitive.dev/oauth/revoke \ -H "Content-Type: application/x-www-form-urlencoded" \ -d token=$TOKEN ``` Connected apps can also be revoked from **Settings -> Connected Apps**. ## Discovery metadata Two metadata documents let clients (and agents) configure auth without hard-coding endpoints: ```http GET /.well-known/oauth-authorization-server GET /.well-known/oauth-protected-resource ``` The authorization-server document (RFC 8414) advertises the `authorization_endpoint`, `token_endpoint`, `revocation_endpoint`, `registration_endpoint`, supported response/grant types, `code_challenge_methods_supported`, and token lifetimes. The protected-resource document (RFC 9728) describes this API as an OAuth-protected resource and points back at the authorization server. ### The 401 challenge A request with a missing or invalid bearer token returns `401 unauthorized` with a spec-shaped `WWW-Authenticate` header pointing at the protected-resource metadata: ```http WWW-Authenticate: Bearer realm="Primitive API", resource_metadata="https://www.primitive.dev/.well-known/oauth-protected-resource" ``` A cold-starting client should fetch that metadata document, follow it to the authorization server, and obtain a token — rather than guessing endpoints or looping on the 401. See the [Errors](/docs/errors) reference for the full envelope. ## Introspection with /v1/whoami `GET /v1/whoami` resolves the presented credential and reports what it authorizes. Use it to confirm the org, user, and credential type before acting: ```bash curl https://api.primitive.dev/v1/whoami \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` ```json { "success": true, "data": { "org_id": "...", "user_id": "...", "role": "owner", "request_id": "req_...", "auth_method": "api_key", "key_id": "..." } } ``` `auth_method` distinguishes an API key from an OAuth token; `key_id` identifies the specific credential (useful for audit and revocation). ## Webhook signing secrets Webhook deliveries are signed with a per-organization secret. Verifiers need this secret to validate that a delivery came from Primitive — see [Signature verification](/docs/signature-verification). Retrieve the current secret (it is generated on first read if the org does not have one yet): ```bash curl https://api.primitive.dev/v1/account/webhook-secret \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` ```json { "secret": "" } ``` Rotate the secret when it may have leaked: ```bash curl -X POST https://api.primitive.dev/v1/account/webhook-secret/rotate \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` ```json { "secret": "" } ``` Rotation is rate-limited to once per 60 minutes; a rotation inside that window returns `rate_limit_exceeded` with a `Retry-After` header. Rotation is dual-signed: deliveries carry signatures from both the previous and the new secret during a short grace window, and the previous secret is only retired once the new one is fully promoted, so a verifier still on the previous secret keeps validating while you roll it over. ## How to obtain credentials You do not start with a credential — you acquire one through one of these flows, then use it as a bearer token everywhere above. * **CLI device login.** Run `primitive login` to authorize the CLI on your machine. It registers a client, opens the browser sign-in, and stores the resulting OAuth tokens locally, refreshing them automatically. See the [CLI reference](/docs/cli). * **Agent signup (headless).** An autonomous agent can self-provision a managed inbox with no API key and only one email-verification code. It can later be upgraded to a full account. See [Skills & Agent Signup](/docs/skills) for the step-by-step flow, and [Which onboarding fits?](/docs/which-onboarding) to choose a path. * **Third-party apps.** Apps acting on behalf of a user use the [OAuth flow](#oauth-authorization-code--pkce) above with Dynamic Client Registration. ## See also * [REST API](/docs/api) — response envelope, pagination, and endpoint inventory. * [Errors](/docs/errors) — the `unauthorized` / `forbidden` codes and recovery guidance. * [CLI](/docs/cli) — `primitive login` and agent signup. * [SDKs](/docs/sdks) — language clients that handle the `Authorization` header for you. * [Signature verification](/docs/signature-verification) — using the webhook signing secret to verify webhooks. * [Quickstart](/docs/quickstart) — get a credential and make your first call. # CLI URL: https://www.primitive.dev/docs/cli Install and use the primitive CLI with Homebrew or npm for auth checks, sends, reads, Functions, domain workflows, and operation discovery. The `primitive` CLI is the recommended interface for agents and humans operating from a terminal. It handles auth, file inputs, command discovery, and common workflows that are awkward with raw HTTP. ## Install ```bash # Homebrew brew install primitivedotdev/tap/primitive # npm npm install -g @primitivedotdev/cli ``` Homebrew installs the required Node.js runtime automatically. npm installs require Node.js 22 or newer. Both install the `primitive` binary. No-install form: ```bash npx @primitivedotdev/cli@latest ``` The CLI ships through the [`primitivedotdev/tap`](https://github.com/primitivedotdev/homebrew-tap) Homebrew tap and as [`@primitivedotdev/cli`](https://www.npmjs.com/package/@primitivedotdev/cli) on npm (the same build is also published unscoped as [`primcli`](https://www.npmjs.com/package/primcli)). Either package installs the `primitive` and `prim` binaries. The CLI is separate from the runtime SDK `@primitivedotdev/sdk`, which is imported from application code. ## Authenticate **CLI** ```bash primitive login primitive whoami ``` **cURL** ```bash curl https://api.primitive.dev/v1/account \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` `primitive login` opens a browser and stores an OAuth token set locally. `whoami` verifies the current token and prints the account context visible to the CLI. To revoke the current CLI session, run: ```bash primitive logout ``` You can also revoke CLI and third-party OAuth grants from **Settings -> Connected Apps**. For non-interactive automation, API keys still work by setting `PRIMITIVE_API_KEY`. ## Send Mail **CLI** ```bash primitive send \ --to alice@example.com \ --subject "Hello" \ --body "Hi from Primitive" \ --wait ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from":"hello@your-org.primitive.email","to":"alice@example.com","subject":"Hello","body_text":"Hi from Primitive","wait":true}' ``` The CLI can infer a valid sender on the managed domain when possible. ## Read Inbound Mail **CLI** ```bash primitive emails:latest --limit 5 primitive emails:get-email --id ``` **cURL** ```bash curl "https://api.primitive.dev/v1/emails?limit=5" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/emails/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Piped table output keeps ids complete so agents can chain commands safely. ## Reply **CLI** ```bash primitive sending:reply-to-email \ --id \ --body-text "Got it." ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/emails//reply \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"body_text":"Got it."}' ``` Primitive derives recipient and threading headers from the inbound email. ## Discover Send Permissions **CLI** ```bash primitive sending:get-send-permissions ``` **cURL** ```bash curl https://api.primitive.dev/v1/send-permissions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Run this before a first outbound send if you are unsure whether a recipient is allowed. ## Functions Create and deploy a hosted Function: **CLI** ```bash primitive functions:init my-fn cd my-fn npm install npm run build primitive functions:deploy --name my-fn --file ./dist/handler.js ``` **cURL** ```bash CODE=$(cat dist/handler.js | jq -Rs .) curl -X POST https://api.primitive.dev/v1/functions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"name\":\"my-fn\",\"code\":$CODE}" ``` `primitive functions:init` is local filesystem scaffolding, so the API equivalent starts when you deploy the built bundle. Test and inspect it: **CLI** ```bash primitive functions:test-function --id primitive functions:list-function-logs --id ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/functions//test \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/functions//logs \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ## Primitive Memories Memories are durable JSON key-value records. CLI calls use org scope by default. Pass `--function ` to use function scope; the id is the function UUID, not the function name. **CLI** ```bash primitive memories set thread:latest '{"email_id":"em_123"}' primitive memories set greeting '"hello"' primitive memories get thread:latest primitive memories search thread: --metadata-only primitive memories delete thread:latest primitive memories set state '{"step":2}' --function primitive memories get state --function ``` **cURL** ```bash curl -X PUT https://api.primitive.dev/v1/memories \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key":"thread:latest","value":{"email_id":"em_123"}}' curl "https://api.primitive.dev/v1/memories?key=thread:latest" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl "https://api.primitive.dev/v1/memories/search?prefix=thread:&include_value=false" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl -X DELETE "https://api.primitive.dev/v1/memories?key=thread:latest" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Values must be valid JSON. Strings must be quoted as JSON strings, so use `'"hello"'`, not `hello`. See [Primitive Memories](/docs/memories) for scopes, TTLs, version checks, search, metadata, and billing. ## Domains Claim a domain, publish its DNS, and verify it for sending and receiving. The topic separator is a space; the colon forms (`primitive domains:add`) still resolve for back-compat. **CLI** ```bash primitive domains add --domain example.com primitive domains verify --id primitive domains list primitive domains zone-file --id --output example.com.zone ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/domains \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"domain":"example.com"}' curl -X POST https://api.primitive.dev/v1/domains//verify \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/domains//zone-file \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` `domains add` returns the exact DNS records to publish. `domains zone-file` downloads those same records as a BIND-format zone file for bulk import (pass `--domain example.com` instead of `--id` if you only have the name). `domains verify` re-checks DNS and reports which records are still missing. See [Receiving](/docs/receiving) for the full inbound setup walkthrough. ## More Commands The CLI mirrors the full REST surface. Beyond the workflows above, these command groups are available: * `primitive endpoints ...` — webhook endpoint CRUD and `test`. See [Endpoints](/docs/endpoints). * `primitive filters ...` — allow/block filter CRUD. * `primitive routes ...`: inbound routing rules (`add`, `list`, `test`, `update`, `reorder`, `remove`); `routes test ` simulates routing for an address. See [Routing](/docs/routing). * `primitive payments ...` — collect and pay USDC over x402 (see below). * `primitive chat ` — send a message to another agent or person and wait for the threaded reply. * `primitive signup ` / `primitive signup confirm `: provision a managed inbox with one email-verification code; the confirm step saves the issued credentials locally. See [Skills & Agent Signup](/docs/skills). * `primitive inbox status` — consolidated inbound readiness view. * `primitive credits redeem ` and `primitive credits balance`: redeem a credit code and read the organization credit balance. See [Credits](/docs/credits#redeem-a-credit-code). Run `primitive list-operations` for the full machine-readable manifest of generated operations, and `primitive describe ` for one operation's parameters and behavior. ## Operation Discovery Agents should use CLI help and operation descriptions before guessing command shapes. **CLI** ```bash primitive --help primitive list-operations primitive describe emails:get-email primitive describe sending:reply-to-email ``` **cURL** ```bash curl https://api.primitive.dev/v1/openapi ``` `list-operations` is the broad machine-readable manifest. `describe` gives focused parameter and behavior details for one operation. ## Proxy Environments On Node 22+ behind a proxy, set: ```bash export NODE_USE_ENV_PROXY=1 ``` Then `HTTP_PROXY` and `HTTPS_PROXY` are respected by Node networking. `primitive doctor` checks the environment. `primitive doctor` is a local environment diagnostic. There is no REST endpoint for inspecting local proxy variables; use `primitive whoami` or the `/v1/account` request above to verify API reachability after setting them. ## Payments The `primitive payments` command group collects and pays USDC with x402. Signing commands read the wallet key from `PRIMITIVE_X402_PRIVATE_KEY` and sign locally. x402 is in an invite-only soft launch; see [Collecting Payments](/docs/collecting-payments) for how to request access. |Command|What it does| |--|--| |`primitive payments register-payout-address`|Register (or update) your org's default payout wallet for a network. Signs an ownership proof locally.| |`primitive payments list-payout-addresses`|List your org's registered payout addresses.| |`primitive payments charge`|Request a synthetic payment (payee). Accepts human USDC via `--amount-usdc`. Prints the challenge JSON to stdout.| |`primitive payments create-challenge`|Low-level synthetic challenge create (payee), base-unit `--amount`.| |`primitive payments create-email-challenge`|Issue an email-native challenge (payee): sends the challenge email from `--from` to `--to` and binds the payment to that thread.| |`primitive payments pay`|Pay a synthetic challenge (payer). Reads the challenge from `--challenge-file`, `--challenge`, or stdin; signs and submits.| |`primitive payments pay-email-step`|Sign an email-native challenge (payer) and print the `interaction.json` payment step to stdout. It does NOT send; you reply on the thread with the printed part.| |`primitive payments get-challenge`|Read a challenge by id (status, settle tx).| |`primitive payments get-spend-policy`|Read your org's payer spend policy.| |`primitive payments update-spend-policy`|Update the payer spend policy (kill-switch, caps, allowlist).| |`primitive payments list-declined-payments`|List recent payments your spend policy refused.| For the synthetic flow see [Collecting Payments](/docs/collecting-payments); for the agent-to-agent email flow see [x402 over Email](/docs/x402-over-email). ## Good Agent Defaults * Run `primitive whoami` before state-changing operations. * Prefer CLI commands for terminal workflows instead of hand-written curl. * Use `--wait` when an immediate send result is needed. * Use `describe` before composing unfamiliar operations. * Never print API keys or webhook secrets in logs. # Collecting Payments URL: https://www.primitive.dev/docs/collecting-payments Collect and pay USDC with x402. Register a payout address, request a payment with a challenge, and settle it non-custodially with a locally-signed authorization. This guide covers the synthetic x402 flow, where the payer is an organization holding an API key and the challenge is passed however you like. For the agent-to-agent variant that carries the challenge, payment, and receipt over a real DKIM-authenticated email thread, see [x402 over Email](/docs/x402-over-email). If you instead received a request and want to pay it, see [Pay an x402 Request over Email](/docs/paying-x402-over-email). Primitive supports [x402](https://www.x402.org/) payments: stablecoin (USDC) payments between agents and organizations, settled directly on-chain. Settlement is non-custodial. Funds move from payer to payee through an EIP-3009 authorization the payer signs with their own key, and Primitive never holds the money or the keys. The flow has two sides: * The **payee** registers a payout address, then creates a **challenge** (a request for a specific amount). * The **payer** receives the challenge, signs the bound authorization locally, and submits it. Primitive verifies every signed field against its own record of the challenge, applies the payer's spend policy, and settles on-chain through a facilitator. Amounts are always in **token base units**. USDC has 6 decimals, so `"10000"` is `0.01` USDC. Supported networks are `base` (mainnet) and `base-sepolia` (testnet). ## Prerequisites * An API key. See [Quickstart](/docs/quickstart). * A wallet you control on the chosen network. The private key signs locally and is never sent to Primitive. The SDKs accept a private key directly; the CLI reads it from the `PRIMITIVE_X402_PRIVATE_KEY` environment variable. * To **receive** payments, a registered payout address (below). To **pay**, USDC in the signing wallet. You do not need to look up your organization id: the CLI and SDKs resolve it for you. ## Requesting access x402 is in an invite-only soft launch. The capability is gated by the `x402_payments` organization entitlement (and `interactions_enabled` for the email-native flow), granted by the Primitive team and off by default. If the x402 endpoints return `feature_disabled`, the feature is not enabled for your organization yet. To request access, contact [support](https://www.primitive.dev/contact) or email the Primitive help agent at `dev@agent.primitive.dev`. ## Setting up a wallet If you do not already have a wallet, create one and fund it. For experimenting, use the `base-sepolia` testnet so no real funds are at risk: ```bash # Create a fresh key with any standard tool, for example: cast wallet new # from Foundry; prints an address and private key export PRIMITIVE_X402_PRIVATE_KEY=0x ``` To **pay** on `base-sepolia`, fund the wallet with test ETH (a Base Sepolia faucet) and test USDC (Circle's testnet faucet). To **receive**, you only need the address; no balance is required. For `base` mainnet, use a real funded wallet. Keep mainnet keys in a secret manager, never in shell history. ## Step 1: Register a payout address (payee) A challenge's `pay_to` is resolved server-side from your registered default payout address, never from client input. Register once per network before requesting payments. You prove control of the address by signing an org-bound message locally; the recovered address becomes your default for that network. Registering a payout address requires an organization owner or admin. An API key acts with the current role of the user who created it, so a key created by a member, or a key with no creator (such as a Function's runtime key), gets a `403 forbidden`. Listing payout addresses is open to every member. **CLI** ```bash export PRIMITIVE_X402_PRIVATE_KEY=0x primitive payments register-payout-address --network base-sepolia ``` The CLI resolves your organization id automatically and prints the registered address. **cURL** The raw API expects an already-signed ownership message (`address`, `network`, `signature`, `issued_at`). Build the signature with one of the SDKs or the CLI rather than by hand: ```bash curl -X POST https://api.primitive.dev/v1/x402/payout-addresses \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "address": "0x...", "network": "base-sepolia", "signature": "0x...", "issued_at": "2026-06-22T12:00:00.000Z" }' ``` **Node.js** ```typescript import { createX402Client } from '@primitivedotdev/sdk/x402'; import { privateKeyToAccount } from 'viem/accounts'; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); const signer = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`); // Your org id is resolved from your account automatically. Pass { org } to override. const address = await x402.registerPayoutAddress( { network: 'base-sepolia' }, { signer }, ); console.log(address.address, address.is_default); ``` **Python** ```python import os from primitive.x402 import create_x402_client, PrivateKeySigner client = create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"]) signer = PrivateKeySigner(os.environ["WALLET_PRIVATE_KEY"]) # Your org id is resolved from your account automatically; pass org=... to override. address = client.register_payout_address(signer=signer, network="base-sepolia") print(address.address, address.is_default) ``` **Go** ```go x402 := primitive.NewX402Client(primitive.X402ClientOptions{ APIKey: os.Getenv("PRIMITIVE_API_KEY"), }) signer, err := primitive.NewPrivateKeySigner(os.Getenv("WALLET_PRIVATE_KEY")) if err != nil { log.Fatal(err) } // Your org id is resolved from your account automatically; set Org to override. address, err := x402.RegisterPayoutAddress(context.Background(), primitive.X402PayoutRegistrationInput{ Network: "base-sepolia", }, signer) if err != nil { log.Fatal(err) } fmt.Println(address.Address, address.IsDefault) ``` ## Step 2: Request a payment (payee) Create a challenge for the amount you want to collect. The response carries everything the payer needs to sign, including the `nonce_binding` and `payment_requirements`. Hand the whole challenge object to the payer, for example in an email reply. **CLI** ```bash # Amount as human USDC; the challenge JSON prints to stdout. primitive payments charge --amount-usdc 0.01 --network base-sepolia # Save it to hand to the payer: primitive payments charge --amount-usdc 0.01 > challenge.json ``` `payments create-challenge` is the equivalent low-level command if you prefer to pass base units with `--amount 10000`. **cURL** ```bash curl -X POST https://api.primitive.dev/v1/x402/challenges \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"amount":"10000","network":"base-sepolia"}' ``` **Node.js** ```typescript const challenge = await x402.charge({ amountUsdc: '0.01', // or amount: '10000' in base units network: 'base-sepolia', description: 'Invoice #1024', }); // Send `challenge` to the payer (e.g. as JSON in an email reply). ``` **Python** ```python challenge = client.charge( amount_usdc="0.01", # or amount="10000" in base units network="base-sepolia", description="Invoice #1024", ) ``` **Go** ```go challenge, err := x402.Charge(context.Background(), primitive.X402ChargeInput{ AmountUsdc: "0.01", // or Amount: "10000" in base units Network: "base-sepolia", Description: "Invoice #1024", }) ``` Challenge creation is idempotent: pass an `Idempotency-Key` header (or `idempotencyKey` in the Node SDK's `charge()`) and a retried create with the same key returns the original challenge instead of minting a duplicate. ## Step 3: Pay a challenge (payer) The payer signs the interaction-bound EIP-3009 authorization locally and submits it. The signing key never leaves the payer's machine. Paying an already-settled challenge returns the original receipt, so a retry is safe. **CLI** ```bash export PRIMITIVE_X402_PRIVATE_KEY=0x # The challenge JSON is whatever the payee gave you. primitive payments pay --challenge-file challenge.json ``` The challenge can also be piped on stdin or passed inline with `--challenge ''`. **cURL** The pay request body is a signed x402 `PaymentPayload`. Build it with an SDK or the CLI; constructing the EIP-3009 signature by hand is not practical: ```bash curl -X POST https://api.primitive.dev/v1/x402/challenges//pay \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"payment": { ... signed PaymentPayload ... }}' ``` **Node.js** ```typescript import { createX402Client, type X402Challenge } from '@primitivedotdev/sdk/x402'; import { privateKeyToAccount } from 'viem/accounts'; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); const signer = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`); const challenge: X402Challenge = JSON.parse(receivedChallengeJson); const receipt = await x402.pay(challenge, { signer }); console.log(receipt.status, receipt.settle_tx); ``` **Python** ```python receipt = client.pay(challenge, signer=signer) print(receipt.status, receipt.settle_tx) ``` **Go** ```go receipt, err := x402.Pay(context.Background(), challenge, signer) if err != nil { log.Fatal(err) } fmt.Println(receipt.Status, receipt.SettleTx) ``` ## Spend policy (payer safety rail) Every payer has a spend policy that gates outbound payments before anything is signed or settled. It has a kill-switch, a per-payment cap, a per-day cap, and an optional payee allowlist. Caps are in token base units. A payment is checked against the **signed** amount, not the amount the challenge advertises. Changing the spend policy requires an organization owner or admin, with the same API key rule as payout addresses. Reading it is open to every member. **CLI** ```bash # Read the current policy primitive payments get-spend-policy # Pause all outbound payments (kill-switch) primitive payments update-spend-policy --paused true # Cap any single payment at 0.05 USDC (run --help for every field) primitive payments update-spend-policy --max-per-payment 50000 ``` **cURL** ```bash curl -X PUT https://api.primitive.dev/v1/x402/spend-policy \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"paused": false, "max_per_payment": "50000", "max_per_day": "1000000"}' ``` The policy is a merge update: fields you omit keep their current value, and an explicit `null` clears a cap. A partial update can never silently re-enable a paused policy. ## Collecting payments from a Function A common pattern is an inbound [Function](/docs/functions) that turns an email into a payment request. The handler creates a challenge and replies in-thread with it, so the sender (or their agent) can pay. ```typescript import { createX402Client } from '@primitivedotdev/sdk/x402'; export default { async fetch(request: Request, env: Record) { // Verify the Primitive signature and parse the inbound event first // (see the Functions guide), then: const x402 = createX402Client({ apiKey: env.PRIMITIVE_API_KEY }); const challenge = await x402.charge({ amount: '10000', network: 'base-sepolia', description: 'Access fee', }); // Reply in-thread with the challenge JSON so the payer can settle it. // ... use the inbound SDK client to reply ... return new Response(JSON.stringify({ ok: true, challenge_id: challenge.id })); }, }; ``` ## Knowing when you have been paid When a challenge you created is settled, Primitive delivers a **`payment.settled`** webhook event to your subscribed endpoints (and a **`payment.failed`** event if settlement fails). This is the real-time way for a payee to learn of payment. The event payload is: ```json { "type": "payment.settled", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": "22222222-2222-4222-8222-222222222222", "settle_tx": "0x..." } ``` `payment.failed` carries `failure_reason` instead of `settle_tx`. Any enabled webhook endpoint receives these unless it filters event types, in which case add `payment.settled` / `payment.failed` to its subscription. You can also read settlement state directly. Poll the challenge until its `status` is `settled` and a `settle_tx` is present: ```bash primitive payments get-challenge --id ``` In code, call `getChallenge(id)` (Node), `get_challenge(id)` (Python), or `GetChallenge(ctx, id)` (Go) and check `status`. A `settled` status carries the on-chain `settle_tx`; `failed` carries a `failure_reason`. The payer's `pay` call also returns the receipt synchronously, so a payee and payer in the same process (or a Function that both charges and settles) get the result immediately without polling. ## Troubleshooting Common errors and how to fix them: * `no_payout_address`: the payee has not registered a payout address for this network. Run `primitive payments register-payout-address --network `. * `payment_declined`: the payer's spend policy refused the payment. Check it with `primitive payments get-spend-policy`. If it is paused, re-enable with `primitive payments update-spend-policy --paused false`; if a cap was hit, raise it. * `settlement_failed`: the on-chain settlement did not complete. The most common cause is insufficient USDC in the paying wallet on that network. Fund the wallet and retry; paying again is safe. * `challenge_expired`: the challenge passed its expiry before being paid. The payee creates a new one. Use `--expires-in` (or `expiresIn`) for longer-lived challenges. * `payment_verification_failed`: the signed payment did not match the challenge. Confirm you are paying with the wallet and network the challenge was issued for. ## Testnet vs mainnet Use `base-sepolia` (testnet) while developing so no real funds are at risk: fund the paying wallet from a Base Sepolia ETH faucet plus Circle's testnet USDC faucet. Switch to `base` (mainnet) only with a real funded wallet, and keep mainnet keys in a secret manager, never in shell history. The network is chosen per challenge; a challenge issued on one network can only be paid on that same network. ## Refunds x402 settlement is final and non-custodial: funds move directly from the payer's wallet to the payee's registered payout address on-chain, and Primitive never holds them. There is no clawback, reverse, or void operation. A refund is therefore a new payment in the opposite direction: the original payee pays the original payer for the refunded amount. Treat it as a fresh forward payment, not an undo of the original. ## Settle limits (email-native) The synthetic flow gates outbound payments with the payer's spend policy (above). The email-native flow additionally enforces per-payee-org settle caps when the payment settles (by default 25 USDC per day and 250 USDC per month per payee org, plus a 10 USDC ceiling on any single settle; the Primitive team can raise all three for an org). See the Settle limits section in [x402 over Email](/docs/x402-over-email) for the full breakdown and the reject reasons. ## Reconciliation `GET /v1/x402/payments` is the payment ledger: a cursor-paginated, newest-first list of every payment this org is party to, both **received** (challenges you issued as payee) and **sent** (challenges you paid). Each row carries the `challenge_id`, `direction`, `status`, `amount`, `asset`, `network`, `counterparty`, and `settle_tx`, so you can enumerate settled payments without already holding each challenge id. Filter with `status`, `direction`, `network`, and the inclusive `created_after`/`created_before` date bounds; page with `limit` (default 50, max 200) and the returned `cursor` — treat `cursor === null` as the end of the ledger, not a short page. Request the same URL with an `Accept: text/csv` header to download the full filtered set as a CSV attachment for month-end reconciliation; if the export hits its size ceiling the response sets `X-Truncated: true`, so narrow the date range and re-pull rather than assuming you have every row. ## Endpoint reference |Operation|Method and path| |--|--| |Register payout address|`POST /v1/x402/payout-addresses`| |List payout addresses|`GET /v1/x402/payout-addresses`| |Create challenge|`POST /v1/x402/challenges`| |Get challenge|`GET /v1/x402/challenges/{id}`| |Pay challenge|`POST /v1/x402/challenges/{id}/pay`| |Get spend policy|`GET /v1/x402/spend-policy`| |Update spend policy|`PUT /v1/x402/spend-policy`| |List declined payments|`GET /v1/x402/declined-payments`| |List payments (ledger)|`GET /v1/x402/payments`| See the [API reference](/docs/api) for the full request and response schemas, and the [SDKs](/docs/sdks) and [CLI](/docs/cli) pages for installation. # MCP Server & Connectors (Claude & ChatGPT) URL: https://www.primitive.dev/docs/connectors Connect Primitive to Claude and ChatGPT as an MCP server so an assistant can read your inbox, search mail, and send or reply to email on your behalf. Primitive ships a hosted [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server. Connect it to an MCP-compatible assistant — such as **Claude** or **ChatGPT** — and the assistant can inspect your account, read and search inbound email, and send or reply to messages through Primitive's authenticated relay. No SDK, SMTP credentials, or DNS setup required. The same endpoint works for any MCP client. Authentication is OAuth, so you connect once and the assistant acts as your Primitive organization. ## Endpoint ```text https://www.primitive.dev/mcp ``` * **Transport:** Streamable HTTP * **Authentication:** OAuth 2.0 (authorization code + PKCE, with dynamic client registration). You sign in with your Primitive account and approve access; the assistant never holds your password. * **Scope:** the connector acts on the Primitive organization you authorize. It only reads and writes email within that organization. ## What the assistant can do Once connected, the assistant has tools for the common email workflows: * **Read** — list and search inbound email, open a full message, follow a conversation or thread, and check inbox and outbound readiness. * **Send** — send a new email, or reply on an existing thread, through your verified domains. These are confirmation-gated: the assistant asks before it sends. * **Manage** — claim and verify domains, manage webhook endpoints and filters, and inspect delivery history. The connector lists the read and send tools by default. The manage tools are listed at `https://www.primitive.dev/mcp/full`, the same server with every tool: add that URL instead if you want an assistant to manage domains, webhooks and filters. Each URL runs only the tools it lists, so an assistant connected to `https://www.primitive.dev/mcp` cannot call the manage tools: it gets an `unknown_tool` error that names `/mcp/full`. Read-only tools are safe to run without confirmation. Tools that send mail are marked as write actions, so a well-behaved assistant confirms the recipient, subject, and body with you first. ## Connect in Claude 1. In Claude, open **Settings → Connectors** (Team and Enterprise plans; an organization owner may need to add it). 2. Choose **Add custom connector**. 3. Enter the server URL: ```text https://www.primitive.dev/mcp ``` 4. Complete the OAuth sign-in with your Primitive account and approve access. 5. Start a new chat and ask, for example, *"List my most recent Primitive emails."* The Primitive tools appear and the email console renders inline. ## Connect in ChatGPT Custom MCP connector support in ChatGPT depends on your plan and rollout; the setting appears under **Settings → Connectors**, or via **Developer mode** where custom servers are enabled. If you don't see either, custom connectors may not yet be available on your ChatGPT plan. 1. In ChatGPT, open **Settings → Connectors** (or enable **Developer mode** to add a custom MCP server). 2. Add a connector pointing at: ```text https://www.primitive.dev/mcp ``` 3. Complete the OAuth sign-in with your Primitive account and approve access. 4. Ask ChatGPT to check your inbox or send a message; the Primitive app actions become available. ## Connect a coding agent or editor Each of these adds the same endpoint. The client opens the OAuth sign-in the first time it connects. **Claude Code** ```bash claude mcp add --transport http primitive https://www.primitive.dev/mcp ``` Then run `/mcp` inside a session and choose `primitive` to sign in. To get the Primitive skills along with the server, install the Claude Code plugin instead of adding the server on its own: ```bash claude plugin marketplace add primitivedotdev/skills claude plugin install primitive@primitive ``` The plugin bundles the same endpoint, the public docs search server, and the [Primitive skills](/docs/skills). Sign in through `/mcp` the same way. **Codex** ```bash codex mcp add primitive --url https://www.primitive.dev/mcp ``` Codex opens the sign-in as part of the add. If you close it, run `codex mcp login primitive` to try again. Or install the Codex plugin, which bundles the server with the [Primitive skills](/docs/skills): ```bash codex plugin marketplace add primitivedotdev/skills codex plugin add primitive@primitive codex mcp login primitive ``` **VS Code** ```bash code --add-mcp '{"name":"primitive","type":"http","url":"https://www.primitive.dev/mcp"}' ``` **Cursor** Use the **Add to Cursor** link on the [integrations page](https://www.primitive.dev/integrations), or add this to `~/.cursor/mcp.json`: ```json { "mcpServers": { "primitive": { "url": "https://www.primitive.dev/mcp" } } } ``` **Any other MCP client** Most clients read the same `mcpServers` entry shown for Cursor. For a client that only launches local (stdio) servers, bridge to the hosted endpoint: ```json { "mcpServers": { "primitive": { "command": "npx", "args": ["-y", "mcp-remote", "https://www.primitive.dev/mcp"] } } } ``` **Without a browser** Where an OAuth sign-in is not possible, such as CI or a headless agent, send a Primitive API key as a bearer token instead: ```bash claude mcp add --transport http primitive https://www.primitive.dev/mcp \ --header "Authorization: Bearer prim_" ``` ## The email console Email-centric results (account summary, inbox status, message lists, and send outcomes) render in an inline **email console** widget where the host supports MCP UI. The widget is fully self-contained — it ships no external scripts and opens no network connections of its own — and it adapts to the host's light or dark theme. Hosts that do not render the widget still receive the same structured result as text. ## Authentication and discovery The connector uses the same public API and OAuth surface documented under [REST API](/docs/api): * API base: `https://api.primitive.dev/v1` * OAuth authorization server metadata: `/.well-known/oauth-authorization-server` (RFC 8414) * OAuth protected resource metadata: `/.well-known/oauth-protected-resource` (RFC 9728) If you prefer to drive the API directly instead of through an assistant, every connector tool maps to a documented endpoint in the [REST API reference](/docs/api). ## Troubleshooting **The connector won't connect.** Confirm the server URL is exactly `https://www.primitive.dev/mcp` (HTTPS, including the `https://`). The endpoint speaks the Streamable HTTP transport; if your client offers a transport choice, pick Streamable HTTP. **Sign-in fails or the OAuth window doesn't complete.** The connector authenticates with your Primitive account over OAuth. Make sure you finish the sign-in and approve the access request in the popup; if it was blocked or closed, remove the connector and add it again to restart the flow. You authorize a specific Primitive organization — if you belong to more than one, sign in to the one whose mail you want the assistant to act on. **No tools appear after connecting.** Start a new conversation after adding the connector so the assistant picks up the tools, then ask something email-related (for example, *"List my most recent Primitive emails."*). If tools still don't appear, disconnect and reconnect the connector. **A tool returns "unauthorized" or a 401.** Your session expired or access was revoked. Reconnect the connector to re-authorize. The server never stores your password; it forwards your token to the Primitive API per request. **Read tools return nothing.** A new Primitive organization has an empty inbox until mail arrives. Check inbox readiness with *"Is my Primitive inbox ready to receive email?"*, confirm a verified domain, and send a test message to your address first. **Sending fails.** Outbound send requires a verified domain. Confirm the `from` address is on a domain that is verified for your organization (the managed `*.primitive.email` address works out of the box), and that the assistant has confirmed the recipient, subject, and body before sending. Still stuck? Contact [support](https://www.primitive.dev/contact), or ask the Primitive help agent directly at `dev@agent.primitive.dev`. ## Privacy The connector reads only the email and configuration in the organization you authorize, and returns only what is needed to answer your request. See the [Privacy Policy](https://www.primitive.dev/privacy) for what Primitive collects, how long it is retained, and how to export or delete it. You can revoke a connector's access at any time from your Primitive account or from the assistant's connector settings. # Agent Funding & Credits URL: https://www.primitive.dev/docs/credits Fund an agent with a spending budget, check and top up its credit balance, redeem credit codes, and buy credit packs through ACP checkout sessions. Agent credits are a prepaid spending budget an agent draws down as it uses Primitive. A human operator funds the budget once on a hosted funding page; from then on the agent can read its remaining balance and top itself up against that budget without a human in the loop. Credits are denominated in your account currency (USD), so a balance of `5_000_000` micros is `$5.00`. This is the **fiat** funding path. It is entirely separate from the on-chain [x402](https://www.x402.org/) flow, where agents settle stablecoin (USDC) payments between each other non-custodially. If you are looking to collect or pay USDC on-chain, see [Collecting Payments](/docs/collecting-payments) and [x402 over Email](/docs/x402-over-email) instead. Use credits when you want a simple prepaid balance that funds your own agent's usage; use x402 when one agent is paying another. There are two ways to add credits to a budget: * **Direct top-up** — the agent calls `POST /v1/credits/topup` to charge the funded budget and grant itself credits. * **ACP checkout** — the agent buys a fixed **credit pack** through an Agentic Commerce Protocol (ACP) checkout session, the open standard for agent purchases. This is a thin standards-compliant wrapper over the same budget machinery. Both draw down the same delegated budget the operator funded. Balance reads never touch payment processing. Amounts on the credits API are in **micros**: `1` USD = `1_000_000` micros. The ACP surface follows the ACP convention of the minor currency unit (cents), so a `$20` pack is `2000`. ## Prerequisites * An API key. See [Quickstart](/docs/quickstart). All credit and checkout requests authenticate with the agent's API key (`Authorization: Bearer prim_...`). * A **funded spending budget** for your organization. An operator sets this up on the hosted funding page (below) before the agent can top up or check out. Without an active budget, `GET /v1/credits/balance` returns `null` and top-ups have nothing to charge. You do not need to look up your organization id: it is resolved from your API key. ## Prerequisite: a funded budget The credit, funding, and checkout endpoints are available to any organization with an API key — there is no separate access grant to request. What gates them is a **funded budget**: until an operator funds one (next step), `GET /v1/credits/balance` returns `null` and top-ups and checkouts have nothing to charge. Agent funding is in beta, so the flow may change. Questions? Contact [support](https://www.primitive.dev/contact) or email the Primitive help agent at `dev@agent.primitive.dev`. ## Step 1: Fund a budget (operator) Funding is a one-time human step. An operator opens a hosted funding page at `/fund/`, where `` is a single-use link you generate for them, and sets a **spending allowance**: * **Budget (max amount)** — the ceiling the agent can spend in total. * **Per-top-up cap** (optional) — the largest single top-up the agent may make. * **Expiry** (optional) — when the allowance stops being usable. The operator saves a payment method on that page. Card details never touch your agent or Primitive's API; the funding page handles them on a hosted, vaulted surface. Once funding completes, the organization has an **active budget** with a delegated allowance, and the agent can draw it down on its own. The allowance caps you set here are enforced on every later charge: a top-up or checkout that would exceed the budget, breach the per-top-up cap, or run past the expiry is refused server-side. ## Step 2: Check the balance Read the current budget with `GET /v1/credits/balance`. There is no human in this loop — the agent calls it directly. ```bash curl https://api.primitive.dev/v1/credits/balance \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` If the organization has an active budget, the response carries it; if not, `budget` is `null`. ```json { "success": true, "data": { "budget": { "currency": "usd", "max_amount_micros": "20000000", "spent_micros": "4500000", "remaining_micros": "15500000", "per_topup_cap_micros": "5000000", "expires_at": "2026-12-31T00:00:00.000Z", "status": "active" } } } ``` |Field|Meaning| |--|--| |`currency`|The budget's currency (e.g. `usd`).| |`max_amount_micros`|Total allowance the operator funded, in micros.| |`spent_micros`|How much of the allowance has been drawn down.| |`remaining_micros`|`max_amount_micros - spent_micros`, floored at zero.| |`per_topup_cap_micros`|Largest single top-up allowed, or `null` for no per-top-up cap.| |`expires_at`|When the allowance expires, or `null` for no expiry.| |`status`|Budget status; an active budget reads `active`.| A `null` budget means no operator has funded one yet (or it is no longer active). Surface the funding page to your operator to create one. The response also carries `prepaid_credit`: the usage credit your organization has bought with top-ups (together with any promotional or granted credit) that can still pay for usage from now on. ```json { "success": true, "data": { "budget": { "...": "..." }, "prepaid_credit": { "currency": "usd", "remaining_micros": "4200000", "next_expires_at": null } } } ``` |Field|Meaning| |--|--| |`prepaid_credit.currency`|The credit's currency, which is your organization's billing currency (e.g. `usd`).| |`prepaid_credit.remaining_micros`|What the credit can still pay for, in micros. It goes down as usage beyond your plan's monthly credit is paid from it.| |`prepaid_credit.next_expires_at`|The earliest expiry among credits with a balance, or `null` when none of them has an expiry date. Top-up credit has no expiry date.| `prepaid_credit` is `null` when there is no such credit. It is left out of the response when an exact figure cannot be given right now, for example in the first few minutes after a top-up while the new credit is being activated; the budget is still returned. ## Step 3: Top up directly To charge the budget and grant credits in one call, `POST /v1/credits/topup` with the amount in micros. An `Idempotency-Key` header is **required** so a retried request never double-charges. ```bash curl -X POST https://api.primitive.dev/v1/credits/topup \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: topup-2026-06-26-001" \ -d '{"amount_micros": "5000000"}' ``` `amount_micros` is a positive integer number of micros (accepted as a number or a string). The response confirms the grant: ```json { "data": { "topup_id": "...", "amount_micros": "5000000", "payment_intent_id": "...", "already_applied": false } } ``` `already_applied` is `true` when a retry with the same `Idempotency-Key` matched an earlier top-up, so you can tell a fresh charge from a replayed one. The credit a top-up buys is added to your organization's prepaid credit (see `prepaid_credit` in Step 2). It pays for usage from the moment of the top-up onward and is used after your plan's monthly credit. Top-up credit has no expiry date and carries over between billing periods until it is used. If your usage was paused because the monthly credit ran out, it usually resumes as soon as the top-up succeeds; in rare cases, such as a top-up made right at the start of a new billing period, it can take a few minutes. A top-up is charged and credited in your organization's billing currency. A spending budget in a different currency is refused with `currency_mismatch` before any charge. A top-up that would exceed the funded budget, breach the per-top-up cap, or run past the allowance expiry is refused. See [Errors](/docs/errors). ## Step 4: Buy a credit pack over ACP The Agentic Commerce Protocol (ACP) surface lets an agent buy credits through the open ACP checkout standard rather than the proprietary top-up. It is the right path when your agent already speaks ACP, or when you want the standard create → complete lifecycle. These routes live at the **API root** (not under `/v1`), carry CORS and `OPTIONS` preflight, and advertise the ACP version they serve in the `API-Version` response header (currently `2025-09-29`; `2025-09-12` is also accepted). ACP responses use the ACP envelope (`{ "type", "code", "message", "param" }` on errors), which differs from the REST `{ "data" | "error" }` shape used everywhere else in the API. ### Credit packs Checkout sells fixed packs. The pack id goes in the `items` array; the credits granted equal the dollars paid. |Pack id|Price|Credits granted| |--|--|--| |`credits_5`|$5|$5 in credits| |`credits_20`|$20|$20 in credits| |`credits_50`|$50|$50 in credits| ### Delegate a payment token Before a session can be completed, the agent vaults a scoped payment token against the funded budget with `POST /agentic_commerce/delegate_payment`. This is what authorizes settlement: completing a checkout requires a token that matches the organization's delegated budget, so a bare API key alone can never settle a cart. The `allowance` you pass bounds what the token may spend. ```bash curl -X POST https://api.primitive.dev/agentic_commerce/delegate_payment \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -H "API-Version: 2025-09-29" \ -d '{ "allowance": { "reason": "agent_credit_purchase", "max_amount": 2000, "currency": "usd" } }' ``` The response carries the vaulted token id as `id`; hold it for the complete step. ```json { "id": "...", "created": "2026-06-26T12:00:00.000Z", "metadata": { "source": "primitive", "idempotency_key": "..." } } ``` The token is a conceptual reference to the vaulted, allowance-scoped payment authorization — there is no raw card data in it, and the actual charge is settled server-side against the funded budget. ### Create a checkout session `POST /checkout_sessions` builds a cart from one or more packs. The session comes back `ready_for_payment` with its line items and totals. ```bash curl -X POST https://api.primitive.dev/checkout_sessions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -H "API-Version: 2025-09-29" \ -d '{ "items": [ { "id": "credits_20", "quantity": 1 } ] }' ``` The `201` response is an ACP session object: an `id` (prefixed `cs_`), `status`, `currency`, `line_items`, and `totals`. Read it again any time with `GET /checkout_sessions/{id}`, or change the cart or buyer with `POST /checkout_sessions/{id}` while the session is still `ready_for_payment`. ### Complete the session `POST /checkout_sessions/{id}/complete` settles the cart: it charges the delegated budget and grants the credits, exactly like a direct top-up. Pass the vaulted token in `payment_data.token`. ```bash curl -X POST https://api.primitive.dev/checkout_sessions/cs_123/complete \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -H "API-Version: 2025-09-29" \ -d '{ "payment_data": { "token": "..." } }' ``` On success the session moves to `completed` and carries an `order`. Completion is **idempotent** — the idempotency key is derived from the session id, so completing the same session twice never double-charges; a second call just returns the already-settled session. If `payment_data.token` does not match a delegated budget, completion fails with `invalid_payment_token`; call `delegate_payment` first. ### Cancel a session `POST /checkout_sessions/{id}/cancel` cancels an unpaid session. A session that is already `completed` cannot be canceled. ```bash curl -X POST https://api.primitive.dev/checkout_sessions/cs_123/cancel \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "API-Version: 2025-09-29" ``` ## Redeem a credit code A credit code adds prepaid usage credit to your organization. It needs no funded budget: the credit shows up in `prepaid_credit` on the balance (Step 2) and pays for usage from the moment it is redeemed. Only an organization owner or admin can redeem a code. An API key redeems with the authority of the user who created it, based on that user's current role, so use a key created by an owner or admin. **CLI** ```bash primitive credits redeem LAUNCH50 primitive credits balance ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/credits/redeem \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: redeem-launch50-001" \ -d '{"code": "LAUNCH50"}' ``` The CLI generates the `Idempotency-Key` for you. Over the API the header is **required**: 1 to 200 printable ASCII characters, without spaces. Retrying with the same key and code returns the original grant with `replayed: true` and adds no second credit; the same key with a different code is refused with `idempotency_key_reused`, so use a new key for each code. ```json { "success": true, "data": { "redemption_id": "...", "amount_micros": "50000000", "currency": "usd", "granted_at": "2026-09-23T17:00:00.000Z", "expires_at": "2026-12-31T00:00:00.000Z", "label": null, "replayed": false, "message": "$50.00 in credits added. Expires Dec 31, 2026. Applies to usage from now on." } } ``` `expires_at` is `null` when the credit does not expire. `message` is a ready-to-show summary of the grant. A refused redemption uses the standard [error envelope](/docs/errors), and its `error.message` is written to be shown to the user as is: |Status|`error.code`|Meaning| |--|--|--| |400|`idempotency_key_required`|The `Idempotency-Key` header is missing.| |400|`validation_error`|The `Idempotency-Key` is empty, longer than 200 characters, or contains anything other than printable ASCII without spaces, or the body is invalid.| |409|`credit_code_already_redeemed`|Your organization or user already redeemed this code, or another code in the same exclusive group (a set of codes of which each organization or user may redeem only one).| |422|`credit_code_invalid`|The code is unknown, expired or used up, or the caller cannot redeem codes for the organization.| |422|`credit_code_not_eligible`|The organization does not meet the terms of the promotion.| |422|`credit_code_balance_cap`|The organization already holds the most promotional credit it can. Use some before redeeming another code.| |422|`idempotency_key_reused`|This `Idempotency-Key` already redeemed a different code.| |429|`rate_limited`|Too many requests or attempts. Wait for `Retry-After` and try again.| |503|`service_unavailable`|The code could not be redeemed right now. Retry later with the same `Idempotency-Key`.| The CLI prints the grant (amount and expiry) on success. On a refusal it prints the `error.message` and exits non-zero. ## Credits vs on-chain x402 Credits and x402 are different payment rails — pick by who is paying whom: ||Agent credits (this page)|x402 ([Collecting Payments](/docs/collecting-payments))| |--|--|--| |Money|Fiat (your account currency, e.g. USD)|Stablecoin (USDC), on-chain| |Who funds|A human operator funds a prepaid budget|An organization or agent pays from its own wallet| |Settlement|Charged against the funded budget|Non-custodial, on-chain via a signed authorization| |Typical use|Funding your own agent's Primitive usage|One agent paying another for a resource| |Standard|ACP checkout, or the direct top-up API|x402, synthetic or [over email](/docs/x402-over-email)| The two are independent. Funding a credit budget has no bearing on x402 wallets or spend policy, and vice versa. ## Endpoint reference Credit endpoints are under `/v1`; ACP endpoints are at the API root. |Operation|Method and path| |--|--| |Get credit balance|`GET /v1/credits/balance`| |Redeem a credit code|`POST /v1/credits/redeem`| |Top up credits|`POST /v1/credits/topup`| |Delegate a payment token|`POST /agentic_commerce/delegate_payment`| |Create checkout session|`POST /checkout_sessions`| |Get checkout session|`GET /checkout_sessions/{id}`| |Update checkout session|`POST /checkout_sessions/{id}`| |Complete checkout session|`POST /checkout_sessions/{id}/complete`| |Cancel checkout session|`POST /checkout_sessions/{id}/cancel`| See the [API reference](/docs/api) for full request and response schemas, [Limits](/docs/limits) for budget and rate constraints, [Errors](/docs/errors) for failure codes, and the [CLI](/docs/cli) page for command-line usage. Pricing for usage that credits pay for is on the [pricing page](/pricing). # Domains URL: https://www.primitive.dev/docs/domains Bring your own domain for inbound MX and outbound DKIM/SPF/DMARC/TLS-RPT with Primitive verification. Use a custom domain when recipients should see your brand in the email address or when an existing mailbox domain should route inbound mail to Primitive. ## Managed vs Custom |Domain type|Best for|DNS work| |--|--|--| |Managed `*.primitive.email`|quickstarts, agents, tests|none| |Custom domain|production branded mail|publish the records Primitive shows you| ## Adding a domain In the dashboard, the Add Domain field accepts a bare domain (`example.com`), a full URL, or an email address. If you paste `you@example.com`, Primitive adds `example.com`. After you add a domain it stays pinned at the top of the list while you publish DNS, and moves to a "Fully added" state once verification completes (inbound, plus outbound if your account sends mail). Domains you added in the last 7 days are grouped under "Recently added". To keep Google Workspace as your mailbox provider and get every message in Primitive as well, add the domain as a [mail relay domain](/docs/mail-relay). ## Inbound Records Primitive gives you MX records for inbound delivery. Publish them at the domain or subdomain you want Primitive to receive. ```text support.example.com. MX 10 mx1.primitive.dev. ``` ## Outbound Records For outbound mail, publish the DKIM, SPF, DMARC, and TLS-RPT records shown in the dashboard. Primitive verifies each record before enabling outbound sends from that domain. ## API DNS Records The domains API returns a `dns_records` array on pending domain claims and verification responses. Each row includes the DNS record type, relative provider name, fully qualified name, exact value, purpose, and verification status. Use those records as the source of truth for CLI and automation flows. Do not guess ownership TXT prefixes, DKIM names, DMARC policy, TLS-RPT values, or SPF merge output. ## Verification After publishing DNS, click **Verify** in the dashboard or call the domains API. DNS propagation can take minutes depending on your provider and TTL. ## Send Gates Verifying a custom domain lets your organization send to addresses at that domain. Broader external sending still depends on account gates documented in [Sending Mail](/docs/sending). ## Letting agents reach your domain The records above are for sending and receiving your own mail. To declare which *other* agents may contact your domain, publish an `_agents` opt-in record — see [Agent Authorization](/docs/agent-authorization). ## Related Pages * [Managed Domain](/docs/managed-inbox): the auto-issued domain that works without DNS. * [Sending Mail](/docs/sending): outbound identity and recipient gates. * [Agent Authorization](/docs/agent-authorization): the `_agents` consent record. * [REST API](/docs/api): domain endpoints. # Endpoints, Filters & Deliveries URL: https://www.primitive.dev/docs/endpoints Manage webhook endpoints and their delivery rules, set allow/block sender filters, and inspect or replay past webhook deliveries. This page covers the three controls that decide **where** inbound mail is delivered and **what** reaches each destination: * **Endpoints** — the HTTP URLs and Primitive Functions that receive your mail, plus the per-endpoint `rules` that filter what they get. * **Filters** — org-wide or per-domain allow/block lists keyed on the sender address. * **Webhook deliveries** — the delivery history you can list, inspect, and replay. This is distinct from [Custom Routes](/docs/routing): a route binds a *recipient address pattern* to a single destination, while this page manages the destinations themselves and the rules that govern delivery to them. For the end-to-end inbound flow, see [Receiving Mail](/docs/receiving). All endpoints below live under `https://api.primitive.dev/v1` and authenticate with a bearer API key. ## Endpoints An **endpoint** is a delivery destination for inbound mail. It comes in two kinds: * `kind: "http"` — a webhook `url` you host. It must be HTTPS and publicly reachable; private and loopback hosts are rejected. * `kind: "function"` — a hosted [Primitive Function](/docs/functions), referenced by `function_id`. No URL is sent over the wire; Primitive dispatches to the function internally. ### Create an endpoint Create an HTTP endpoint: ```bash curl -X POST https://api.primitive.dev/v1/endpoints \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "http", "url": "https://api.acme.dev/hooks/primitive", "enabled": true }' ``` Create a function-backed endpoint instead by passing `function_id` and omitting `url`: ```bash curl -X POST https://api.primitive.dev/v1/endpoints \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "kind": "function", "function_id": "" }' ``` `kind` defaults to `"http"`. For an HTTP endpoint, `url` is required and `function_id` is not allowed; for a function endpoint, `function_id` is required and `url` is not allowed. `enabled` defaults to `true`. Pass `domain_id` to scope the endpoint to one of your organization's domains (see the active-endpoint rule below). A function has at most one endpoint — recreating it returns the existing row. ### The one-active-endpoint-per-domain rule Each domain has a single **default destination** slot. An organization also has one org-wide fallback slot (the endpoint with `domain_id: null`), used for domains that have no scoped endpoint of their own. * Only **one** active, default-destination endpoint may occupy a given domain's slot (or the org-wide fallback slot). Creating or re-enabling a second one returns `409 conflict`; delete the existing endpoint first. * Endpoints created with `is_route_target: true` are exempt from this slot. They are reachable only through an explicit [route](/docs/routing) and never serve as a domain default, so many of them can share a domain. Routes mint these for you when you point a route at a `function_id`. Plan limits apply to the default slots: the free Developer plan allows a single global (org-wide) webhook, and per-domain endpoints require a paid plan. Your effective `webhooks_max_global` and `webhooks_per_domain` limits are reported by [Limits](/docs/limits). ### List, update, and delete ```bash # list active endpoints curl https://api.primitive.dev/v1/endpoints \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" # change the URL, scope, rules, or enabled flag curl -X PATCH https://api.primitive.dev/v1/endpoints/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' # remove an endpoint curl -X DELETE https://api.primitive.dev/v1/endpoints/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` `PATCH` accepts `url`, `enabled`, `domain_id`, and `rules`. You cannot set `url` on a function endpoint. Deleting an endpoint deactivates it; deactivated endpoints stop receiving mail and free up their domain slot. ### The `rules` object Every endpoint carries a `rules` object that filters which messages it receives. **All fields are optional and ANDed together** — a message must pass every rule present to be delivered. An empty object (`{}`, the default) applies no filtering. |Field|Type|Meaning| |--|--|--| |`max_size_bytes`|integer|Maximum total email size in bytes. Larger messages are not delivered to this endpoint.| |`exclude_attachments`|boolean|When `true`, deliver only messages that have no attachments.| |`attachment_limit_mb`|number|Maximum total attachment size in MB. Larger messages are skipped.| |`sender_whitelist`|array of emails|When set, deliver **only** from these sender addresses.| |`sender_blacklist`|array of emails|When set, never deliver from these sender addresses.| |`event_types`|array of strings|Event types this endpoint subscribes to (1 to 50 entries). **Omitted = receive every event type except the opt-in `sent_email.*` events**, which an endpoint receives only if it lists them. An empty array is rejected; omit the field instead.| The size and attachment rules (`max_size_bytes`, `attachment_limit_mb`, `exclude_attachments`) are evaluated against a fast approximation of the message size, not an exact byte count, so treat their thresholds as approximate. Set rules at create time or with `PATCH`: ```bash curl -X PATCH https://api.primitive.dev/v1/endpoints/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "rules": { "max_size_bytes": 5242880, "exclude_attachments": true, "sender_whitelist": ["alerts@acme.dev"], "event_types": ["email.received"] } }' ``` #### How `event_types` controls subscription `event_types` decides which event types reach the endpoint. If you **omit** it, the endpoint receives every event type except the opt-in `sent_email.*` events. This is the default and the grandfathered behavior for endpoints that predate event subscriptions. If you set it, the endpoint receives only the listed types (for example `email.received`, `email.bounced`) and nothing else. See [Webhook Payload](/docs/webhook-payload) for the full list of event types and their field semantics. `sent_email.accepted`, `sent_email.delivered`, `sent_email.failed` and `sent_email.completed` (events about mail you send, see [Sent Email Events](/docs/webhook-payload#sent-email-events)) are delivered only to an endpoint that lists them, and only for sends made after the endpoint first listed one of them. They carry a different payload from inbound mail, so we recommend a dedicated endpoint; an endpoint that also handles inbound mail must keep its inbound event types in the list. Route-target endpoints, pull endpoints and Function endpoints do not receive them, and a domain-scoped endpoint receives them only for sends from its domain. Subscriptions are matched by **exact string**, and values are not validated against the known event names. A typo like `email.bouncd` saves without error and silently matches nothing, so the endpoint quietly receives no events of that kind. Copy event names verbatim from [Webhook Payload](/docs/webhook-payload). A [test send](#test-an-endpoint) only validates `email.received`; verify specialized subscriptions such as `email.bounced` with a real matching event or by checking [webhook deliveries](#webhook-deliveries). The other `rules` fields filter on message content (size, attachments, sender); `event_types` filters on the event itself. They compose: an endpoint can subscribe to only `email.received` *and* require the message be under 5 MB *and* come from a whitelisted sender. ### Test an endpoint Send a synthetic `email.received` payload to an HTTP endpoint to confirm it is reachable and that your signature verification works: ```bash curl -X POST https://api.primitive.dev/v1/endpoints//test \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` The response reports the HTTP `status` your endpoint returned, a truncated `body`, and the `signature` that was sent. The test payload is signed exactly like a real delivery, so use it to validate your [signature verification](/docs/signature-verification). Testing applies to HTTP endpoints only — there is nothing to call for a function-backed endpoint. Because the test always sends an `email.received` payload, it confirms reachability and signing but cannot validate a specialized `event_types` subscription (such as `email.bounced`). Test sends are rate limited to **4 per minute** and **30 per hour** per organization. Successful tests, and tests against a URL on one of your verified domains, do not count against the per-minute limit. ## Filters Filters are allow/block lists evaluated on the **sender address**, applied in addition to any per-endpoint `sender_whitelist` / `sender_blacklist` rules. A filter has a `type` and a `pattern`: * `type: "whitelist"`: matches the **From address**. A matching sender skips the blocklist and the spam threshold only when that From authenticates: DMARC-aligned SPF or DKIM, or, for a domain with no DMARC evaluation, a signature the sending provider attests for that domain. A message that got past a blocklist or the spam threshold only because of the whitelist, and then fails authentication, is not delivered and shows as rejected (`sender_blocked`). Mail sent through an email service provider matches on its From address, not its bounce address. * `type: "blocklist"`: matches the **envelope sender** (SMTP `MAIL FROM`) or the From address, and rejects the message. When a blocklist rule and a whitelist rule both match, the more specific rule wins. An exact address (`user@example.com`) is more specific than a whole domain (`*@example.com` or `example.com`), which is more specific than a wildcard domain (`*.example.com`), which is more specific than `*`. So a blocklist entry for `user@example.com` still rejects that address even when `example.com` is whitelisted. When both rules are equally specific, the whitelist rule applies. For allowlist-only mode, add whitelist rules for your trusted senders and a `*` blocklist rule to reject everyone else. ```bash # block a specific sender org-wide curl -X POST https://api.primitive.dev/v1/filters \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "blocklist", "pattern": "spam@bad.example" }' ``` Patterns are normalized to lowercase and trimmed before storage, and may be up to 500 characters. New filters are created enabled. ### Per-domain scoping Pass `domain_id` to scope a filter to a single one of your organization's domains instead of the whole organization: ```bash curl -X POST https://api.primitive.dev/v1/filters \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "whitelist", "pattern": "partner@acme.dev", "domain_id": "" }' ``` Per-domain filters require a paid plan; org-wide filters (`domain_id` omitted or `null`) are available on all plans. ### List, toggle, and delete ```bash # list filters, newest first curl https://api.primitive.dev/v1/filters \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" # enable or disable a filter (the only patchable field) curl -X PATCH https://api.primitive.dev/v1/filters/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' # remove a filter curl -X DELETE https://api.primitive.dev/v1/filters/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` `PATCH` only toggles `enabled`. To change a pattern or type, delete the filter and create a new one. ## Webhook deliveries Every attempt to deliver an event to an endpoint (an inbound email, or an opt-in `sent_email.*` event) is recorded as a **delivery**. The delivery history is your audit trail for what fired, when, and whether it succeeded. ### List deliveries ```bash curl "https://api.primitive.dev/v1/webhooks/deliveries?limit=50&status=failed" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Supported query parameters: |Parameter|Meaning| |--|--| |`limit`|Page size, 1–100 (default 50).| |`cursor`|Opaque cursor from the previous page's `meta.cursor`, for pagination.| |`email_id`|Only deliveries for this inbound email.| |`event_type`|Only deliveries of this event type, for example `sent_email.failed`.| |`sent_email_id`|Only deliveries of `sent_email.*` events about this sent email.| |`status`|One of `pending`, `delivered`, `header_confirmed`, `failed`, `skipped_by_rules`.| |`date_from` / `date_to`|ISO 8601 timestamps bounding `created_at`.| Results are newest-first. The response `meta` includes `total` and a `cursor` for the next page (null when there are no more). ### Inspect a delivery ```bash curl https://api.primitive.dev/v1/webhooks/deliveries/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ```json { "id": "184293", "org_id": "7f2c1d84-0a5b-4c96-8e37-2b6d9f01c4a8", "email_id": "a0d6f29c-4e13-4b87-9c52-1f8a3e7b6d09", "event_type": "email.received", "sent_email_id": null, "endpoint_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31", "endpoint_url": "https://api.acme.dev/hooks/primitive", "status": "failed", "attempt_count": 3, "duration_ms": 812, "last_error": "HTTP 500: internal error", "last_error_code": "http_500", "created_at": "2026-06-26T14:03:11.000Z", "updated_at": "2026-06-26T14:08:42.000Z", "email": { "sender": "alerts@acme.dev", "recipient": "ops@yourdomain.com", "subject": "Disk usage warning" } } ``` `status` is `delivered` (2xx), `header_confirmed` (2xx plus a confirmation header from your handler), `failed`, or `pending`. `last_error` and `last_error_code` explain failures. `event_type` is the delivered event type (null for deliveries recorded before event types existed). A `sent_email.*` delivery has `email_id` and `email` set to `null` and `sent_email_id` set to the send it is about. For a function-backed endpoint, `endpoint_url` is an opaque `function://` identifier rather than a callable URL. Deliveries to a function-backed endpoint can carry these `last_error_code` values in addition to the ordinary HTTP failure codes: * `function_timeout`: the invocation exceeded the 10-minute wall-clock window and was canceled. The delivery is retried on the standard schedule. See [Functions](/docs/functions#limits) for how the wall-clock and CPU limits relate. * `content_discarded`: the stored email content was discarded before the delivery could complete, so there was nothing left to deliver. * `superseded`: another delivery to the same endpoint already succeeded for this email, so this one was skipped rather than delivered twice. * `queue_dead_letter`: the dispatch could not be completed after repeated attempts. Replay the delivery to try again. ### Replay a delivery After fixing a handler, re-send the stored payload to the endpoint: ```bash curl -X POST https://api.primitive.dev/v1/webhooks/deliveries//replay \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ```json { "delivered": 1, "failed": 0 } ``` Replay of an inbound email delivery reuses the original stored payload and event id, so your handler's idempotency rules still apply: the same email is not re-ingested. Replay re-sends to the original endpoint; if it has been deleted or deactivated (delete is a soft-deactivate), the replay is rejected. Note that merely disabling an endpoint (`enabled: false`) does not block replay: the original still receives it. Replay supports inbound email deliveries and `sent_email.*` deliveries, and is rate limited per organization. A `sent_email.*` delivery is replayed with the same event id but a payload rebuilt from the send as it is now; it is rejected if the send was deleted or the endpoint is no longer an active http endpoint. To re-deliver a whole inbound email (rather than one delivery row), see the replay flow in [Receiving Mail](/docs/receiving). ## Related pages * [Custom Routes](/docs/routing): bind recipient address patterns to destinations. * [Receiving Mail](/docs/receiving): the end-to-end inbound flow. * [Webhook Payload](/docs/webhook-payload): event types and field semantics. * [Signature Verification](/docs/signature-verification): verify the signed payloads endpoints and tests send. * [Functions](/docs/functions): hosted inbound handlers. * [Connectors](/docs/connectors): managed integrations. * [Errors](/docs/errors) and [Limits](/docs/limits): error codes and plan limits. * [API Reference](/docs/api): the full v1 surface. # Errors URL: https://www.primitive.dev/docs/errors Typed error envelope, error codes, and recovery guidance for the Primitive v1 REST API. Every error response from `https://api.primitive.dev/v1` uses the same envelope so agents and SDKs can branch on machine-readable codes without parsing HTTP status text or scraping prose. ## Envelope ```json { "success": false, "error": { "code": "unauthorized", "message": "Invalid or missing API key" } } ``` * `error.code` is a stable machine-readable identifier. Agents and SDKs branch on this value, not on the human-readable `message`. * `error.message` is a short human-readable description. It can change between releases without warning. Do not parse it. * When escalating to support, include the request timestamp and the endpoint called, plus any identifier you have: your `Idempotency-Key` if you sent one, or the returned email `id` when the request got far enough to produce one. Validation errors name the rejected fields in `error.message`, with each field path prefixed onto its failure (`field: reason`): ```json { "success": false, "error": { "code": "validation_error", "message": "from: Invalid email address" } } ``` Send denials (`recipient_not_allowed`) additionally carry an `error.gates[]` array. Each entry describes one failed permission gate so an agent can branch on a stable reason and self-correct: ```json { "success": false, "error": { "code": "recipient_not_allowed", "message": "Recipient is not allowed for this organization yet", "gates": [ { "name": "send_to_known_addresses", "reason": "recipient_not_known", "message": "You have not exchanged authenticated mail with this address yet.", "subject": "alice@external.com", "fix": { "action": "wait_for_inbound", "subject": "alice@external.com" }, "docs_url": "https://docs.primitive.dev/docs/sending" } ] } } ``` * Branch on `gates[].reason` (and act on `gates[].subject`), never the human `message`. Reasons are stable and additive — new gates may appear, existing ones are not renamed in place. * `gates[].fix` is present only when there is a customer-side action; some denials (for example, the sender must fix their own DNS) carry no `fix`. When present, `fix.subject` is the entity the action applies to. `send_to_known_addresses` reports why the most recent inbound from the recipient did not unlock replies: |Reason|Meaning|Fix action| |--|--|--| |`recipient_not_known`|No mail has been received from this address.|`wait_for_inbound`| |`recipient_grant_pending`|A message from this address was accepted but its grant decision has not been written yet. Transient.|`retry_shortly`| |`recipient_grant_not_recorded`|Mail was received from this address, but no grant decision was ever recorded for it (it predates decision recording, or recording did not complete). Not transient; a new message is needed.|`wait_for_inbound`| |`recipient_unauthenticated`|Mail was received but the sender did not authenticate (DMARC failed).|`sender_must_fix_authentication`| |`recipient_weak_binding`|The sender authenticated, but their DKIM signature does not cover the `To` header, so the message could not be bound to your inbox. Unlocks once their provider signs `To` or a message from them passes SPF.|`sender_dkim_must_sign_to_header`| |`recipient_only_sent_reports`|Only bounces or automated reports have arrived from this address; those never unlock replies.|`wait_for_inbound`| |`recipient_grant_replay_denied`|The last message failed replay defense (stale, future-dated, duplicate or missing `Message-ID`, your address not in `To`/`Cc`, or the check itself failed). A fresh message is needed.|`wait_for_inbound`| |`recipient_grant_denied`|A message was received but did not qualify to unlock replies for another reason.|`wait_for_inbound`| ## Error codes |Code|HTTP status|Meaning|Recovery| |--|--|--|--| |`unauthorized`|401|Missing or invalid bearer token. The response carries a spec-shaped `WWW-Authenticate: Bearer realm="Primitive API", resource_metadata="..."` header pointing at the protected-resource metadata.|Acquire a token via the discovery flow at `/auth.md`, then retry. Do not loop.| |`download_token_expired`|401|The signed download URL (from a webhook payload) is past its 24-hour validity window. The content itself is still stored. Returned only by `/v1/emails/{id}/raw` and `/v1/emails/{id}/attachments.tar.gz`.|Retry the same URL with `Authorization: Bearer YOUR_API_KEY` and no `token` parameter; API-key downloads do not expire. `error.details.remediation` carries this instruction.| |`download_token_invalid`|401|The `token` query parameter on a download URL failed verification (wrong signature, audience, or email id).|Use the signed URL exactly as delivered in the webhook payload, or switch to API-key auth as for `download_token_expired`. Do not retry with the same token.| |`forbidden`|403|The bearer is valid but lacks the scope required for the operation.|Reissue the token with the missing scope, or surface the missing scope to the human owner. Do not retry with the same token.| |`not_found`|404|The resource id does not exist or is not visible to the current organization.|Verify the id was returned from a previous call in the same org context. Do not retry.| |`validation_error`|400|Request body or query parameters failed schema validation. `error.message` names the rejected fields.|Fix the fields named in `message`, retry once. Do not loop without changing input.| |`mx_conflict`|409|A domain claim conflicts with an existing mailbox provider on the same domain.|Surface the conflict to the user; pass `confirmed: true` in the request body to override.| |`conflict`|409|The request conflicts with current state (for example, a resource was modified concurrently).|Re-read the resource, reconcile, and retry.| |`rate_limited`|429|An organization rate limit was exceeded. Returned by the org-wide API limiter and by `/v1/send-mail`. The API request limit is 3000 per minute for Developer, Power, and Platinum organizations, and 120 per minute for agent-tier organizations; see [Limits](/docs/limits).|Honor the `Retry-After` response header before retrying. Implement client-side back-off.| |`rate_limit_exceeded`|429|Rate limit exceeded on a per-resource limiter (for example domain, CLI/agent signup, and webhook-secret rotation routes). Same meaning as `rate_limited`.|Treat the same as `rate_limited`: branch on the `429` status, honor `Retry-After`.| |`outbound_capacity_exhausted`|503|Outbound capacity is temporarily exhausted. Returned by `/v1/send-mail`; see [Outbound relay failures](#outbound-relay-failures) for the full relay error set.|Retry with exponential back-off. No `Retry-After` header is sent on this response. Do not exceed 5 attempts; escalate persistent failures with the request timestamp and endpoint.| |`internal_error`|500|An unhandled fault on the server side. Include the request timestamp and endpoint when escalating.|Retry once with back-off, then escalate with the request timestamp and endpoint if persistent.| ## Send and reply errors These codes come from the sending and sent-email management endpoints: `POST /v1/send-mail`, the reply/forward endpoints, the reschedule/cancel endpoints, and the read endpoints. |Code|HTTP status|Meaning|Recovery| |--|--|--|--| |`recipient_not_allowed`|403|A first-send gate refused the recipient. The response carries an `error.gates[]` array (see below) describing each failed gate.|Inspect `gates[]`; follow `gates[].fix.action` where present (for example, wait for inbound, or verify a domain).| |`cannot_send_from_domain`|403|The `from` address is not a verified outbound sender for your org. `error.details.valid_senders` lists the domains you can send from.|Send from a listed domain, or verify the `from` domain. Check `GET /v1/outbound/status`.| |`inbound_not_repliable`|422|The inbound email cannot be replied to — it was rejected at ingestion, its content was discarded, or it lacks a Message-ID/recipient. `error.details.reason` says which.|Do not retry. Start a fresh send instead.| |`not_scheduled`|409|A reschedule (`PATCH /v1/sent-emails/{id}`) or cancel targeted a send that is no longer scheduled: it already executed, is executing now, or was already canceled. The message names the current status.|Read `GET /v1/sent-emails/{id}` for the current state. Do not retry.| |`discard_not_enabled`|403|Content discard was requested but the org has not opted in to the feature.|Enable content discard in webhook settings first.| |`search_timeout`|504|A search query exceeded its time budget.|Narrow the date range or add more filters, then retry.| ### Outbound relay failures When the outbound relay itself fails, the send returns one of these. The same `Idempotency-Key` makes a retry safe. |Code|HTTP status|Meaning|Recovery| |--|--|--|--| |`outbound_capacity_exhausted`|503|The outbound relay is temporarily at capacity.|Retry with exponential back-off; no `Retry-After` header is sent.| |`outbound_unreachable`|502|The outbound relay could not be reached at all: the connection failed before the request was delivered, so nothing was sent.|Retry with back-off.| |`outbound_relay_timeout`|504|The outbound relay accepted the request but did not answer within the deadline. The message may still be in flight, so a retry of the same idempotency key replays this result rather than sending a second copy.|Retry with back-off. Reuse the same `Idempotency-Key`; a new key can produce a duplicate delivery.| |`outbound_relay_failed`|502|The relay returned an unclassified failure.|Retry with back-off, then escalate with the request timestamp and endpoint.| |`outbound_response_malformed`|502|The relay returned an unexpected response.|Retry once, then escalate with the request timestamp and endpoint.| |`outbound_key_invalid`|500|A server-side outbound credential is invalid.|Escalate with the request timestamp and endpoint; retrying will not help until fixed.| ## x402 payment errors These codes come from the x402 endpoints (synthetic and email-native). See [Collecting Payments](/docs/collecting-payments) and [x402 over Email](/docs/x402-over-email). x402 is in an invite-only soft launch; an org without access gets `feature_disabled`. |Code|HTTP status|Meaning|Recovery| |--|--|--|--| |`feature_disabled`|403|x402 is not enabled for your organization. `error.details` names the missing entitlement (`missing_entitlement`), a human `message`, and a `docs_url` pointing at the relevant "Requesting access" section.|Read `error.details.docs_url` and request access. Do not retry.| |`no_payout_address`|422|The payee has no payout wallet for this network (no address-bound wallet and no org default).|Register one with `primitive payments register-payout-address --network `.| |`payment_declined`|422|The payer's spend policy refused the payment (paused, cap exceeded, or payee not allowlisted).|Inspect with `primitive payments get-spend-policy`; un-pause or raise the cap, then retry.| |`payment_verification_failed`|422|The signed payment did not match the recorded challenge (wallet, network, amount, or bound nonce).|Re-sign against the exact challenge you are answering. Do not retry unchanged.| |`settlement_failed`|502|The on-chain settlement did not complete, most often insufficient USDC in the paying wallet.|Fund the wallet and have the payee issue a fresh challenge; paying again is safe.| |`challenge_expired`|422|The synthetic-flow pay endpoint was called for a challenge past its expiry. In the email-native flow an expired challenge is not an API error: it surfaces as a `reject` step on the thread, whose reason is `challenge_expired`, matching this code.|The payee issues a new challenge; use a larger `expires_in` for slower payers.| The email-native settle path additionally enforces per-payee-org settle caps. These surface as a `reject` step on the thread and a `payment.failed` webhook event (not as an API error code, because the settle happens after the payment reply arrives): |Reason|Meaning| |--|--| |`settle_daily_cap_exceeded`|The payee org's daily settle cap (default 25 USDC) is full for the UTC day.| |`settle_monthly_cap_exceeded`|The payee org's monthly settle cap (default 250 USDC) is full for the UTC month.| |`amount_exceeds_per_settle_ceiling`|This single settle is over the per-settle ceiling (default 10 USDC).| The daily and monthly caps, and the per-settle ceiling, are all raisable per organization by the Primitive team. See the Settle limits section in [x402 over Email](/docs/x402-over-email). ### Structured failure reasons The four top-level codes above are stable umbrellas. To let an auto-pay agent branch on *why* a payment failed without parsing the human `message`, every x402 failure also carries machine-readable detail: ```json { "success": false, "error": { "code": "settlement_failed", "message": "facilitator settlement failed", "details": { "reasons": ["facilitator_unavailable"], "failure_category": "settlement" } } } ``` * `details.reasons` is an array of stable subreason codes (a single-element array when there is one reason). The human `message` is preserved unchanged alongside it. * `details.failure_category` is one of `declined`, `cap`, `verification`, `settlement`. * The same `failure_category` and `reasons` are also added to the `payment.failed` webhook payload, so an async subscriber can branch identically. The subreason codes per failure path: **`payment_declined` (category `declined`)** — the payer's spend policy refused it. All permanent until the policy changes; the challenge stays pending so you can adjust and retry. |Reason|Meaning| |--|--| |`paused`|x402 payments are paused for the org (kill-switch on).| |`per_payment_cap`|The signed amount exceeds the per-payment limit.| |`daily_cap`|The signed amount would exceed the daily limit.| |`allowlist`|The payee counterparty is not on the spend allowlist.| |`spend_non_positive_amount`|The payment amount is not positive (malformed challenge).| **`payment_verification_failed` (category `verification`)** — the signed payment did not match the recorded challenge. All permanent; re-sign against the exact challenge. `reasons` may carry several codes at once (index-aligned with the `message`). |Reason|Meaning| |--|--| |`signature_unrecoverable`|The payment signature did not recover.| |`signer_mismatch`|The recovered signer does not match `authorization.from`.| |`invalid_from_address` / `invalid_to_address`|`from` / `to` is not a valid address.| |`payer_mismatch`|`authorization.from` does not match the expected payer.| |`payto_mismatch`|`authorization.to` does not match the challenge payTo.| |`self_send`|The payer and payee wallets are the same address.| |`amount_mismatch`|`authorization.value` does not match the expected amount.| |`verify_non_positive_amount`|`authorization.value` is not positive.| |`nonce_mismatch`|`authorization.nonce` is not the interaction-bound nonce.| |`degenerate_window` / `window_too_wide`|The authorization validity window is degenerate or too wide.| |`insufficient_headroom`|The authorization has expired or lacks settlement headroom.| |`not_yet_valid`|The authorization is not yet valid (`validAfter` in the future).| |`malformed_payload`|The x402 payment payload was malformed.| |`unsupported_network`|The challenge network is not supported.| **`settlement_failed` (category `settlement`)** — the on-chain settlement did not complete. This is where retryable vs permanent vs misconfig diverge: |Reason|Retry guidance| |--|--| |`insufficient_funds`|**Permanent.** Fund the paying wallet and have the payee issue a fresh challenge.| |`facilitator_unavailable`|**Transient.** The same signed payment may succeed later. The 502 carries a `Retry-After` header (seconds); honour it and retry the same payment.| |`settlement_misconfigured`|**Operator misconfig** (missing facilitator credentials). Retrying without an operator fix will keep failing.| |`settlement_failed`|An unclassified facilitator failure. Treated as transient (also carries `Retry-After`).| **`challenge_expired` (category `settlement`)** — `reasons: ["challenge_expired"]`. Permanent for this challenge; the payee must issue a new one. **Settle-cap rejects (category `cap`)** — the email-native rail's per-payee-org cap rejects (`settle_daily_cap_exceeded`, `settle_monthly_cap_exceeded`, `amount_exceeds_per_settle_ceiling`, documented above) surface with `failure_category: "cap"` in the `payment.failed` webhook payload. Only `facilitator_unavailable` (and the generic `settlement_failed`) are retryable, and only those set `Retry-After`. Every other reason is permanent or a misconfiguration: retrying the *same* payment will fail the same way. ## Rate-limit responses A rate-limited request gets a `429` with a `Retry-After` header plus the standard rate-limit headers: ```http Retry-After: 30 ratelimit-limit: 3000 ratelimit-remaining: 0 ratelimit-reset: 1718900000 ratelimit-policy: 3000;w=60 ``` This example shows the standard organization's API request limit. `Retry-After` is in seconds, `ratelimit-reset` is a Unix timestamp (seconds), and `ratelimit-limit` reports the limit that rejected the request. The value is 120 for agent-tier API traffic and unauthenticated IP traffic; send and per-resource limits can have other values. On a `429` all four `ratelimit-*` headers describe the limiter that rejected the request, never a different budget that still has room, and the `w=` value in `ratelimit-policy` is that limiter's own window in seconds: 60 for the API request limits, 3600 and 86400 for the hourly and daily send caps (for example `1000;w=3600`), and the limiter's own window on per-resource limits (a webhook test send limited to 4 per minute reports `4;w=60`). Agents should sleep for at least `Retry-After` seconds before retrying the same operation. The hourly and daily send caps are rolling windows rather than fixed ones, so on those two limiters `Retry-After` and `ratelimit-reset` report when the bucket will next admit a request, which can be well before the end of the current hour or day. The body matches the standard envelope; branch on the `429` status, since the code is either `rate_limited` (org-wide limiter, `/v1/send-mail`) or `rate_limit_exceeded` (per-resource limiters): ```json { "success": false, "error": { "code": "rate_limited", "message": "Rate limit exceeded" } } ``` A few `429` responses carry `Retry-After` and no `ratelimit-*` headers, because what refused the request reports only how long to wait: the signup and OAuth `slow_down` responses, the public demo endpoints, the hosted MCP server, and an abuse backstop in front of the API that answers before a request reaches the rate limiters (code `rate_limit_exceeded`, `Retry-After: 60`). A `429` whose code is not `rate_limited`, `rate_limit_exceeded` or `slow_down` is a different kind of cap, such as a concurrency or attempt limit, and may carry no `Retry-After` at all; read its message. In every case, when `Retry-After` is present it is the value to wait for. The same four `ratelimit-*` headers are on other v1 API responses too, so a client can track its budget before it runs out. On those responses they describe the API request budget the request was charged to. A `401` for a missing or invalid token reports the per-IP bucket that the request was charged to (120 per minute). The public GraphQL endpoint (`/v1/graphql`) reports its own per-IP bucket on every query response. Two cases carry less: organizations exempted from the API limit get no `ratelimit-*` headers, and while the limiter is briefly unavailable a response carries no live bucket state (a `401` then carries only `ratelimit-limit` and `ratelimit-policy`). Treat a missing `ratelimit-remaining` as unknown, not as zero. ## Idempotency The `/send-mail` endpoint accepts an optional `Idempotency-Key` request header. When provided, replays of the same key return the original outcome (including the `Idempotency-Key` response header echoing the effective value) rather than firing a second send. Use this for any operation an agent might retry after a network failure. If you omit the header, Primitive derives a key from the canonical request payload and a fixed 5-minute window, and returns it in the `Idempotency-Key` response header. Two keyless sends with identical content to the same recipients inside one window are therefore a single send: the second sends nothing and returns the first send's result. That is `200` with `idempotent_replay: true` when the first send was accepted. When the first send failed, it is that send's stored error with its original status (for example `504` `outbound_relay_timeout`), not a new attempt. A replay is always marked. The response carries an `Idempotent-Replayed: true` header and an `idempotency` object whose `key_source` is `explicit`, `auto_content` or `function_trigger`. The object is in `data` on a `200` and in `error.details` on a replayed error. Pass your own `Idempotency-Key` per logical send to send identical content again. See [Sends without a key](/docs/sending#sends-without-a-key). ## Webhook signature failures Webhook delivery failures appear in webhook event logs, not in API responses. See the [signature verification guide](/docs/signature-verification) for verifier logic. ## Versioning and deprecation The error envelope is part of the v1 contract. Breaking changes will only land in a new major version (`/v2`). See [API versioning](/docs/versioning). # FAQ URL: https://www.primitive.dev/docs/faq Common Primitive integration questions about sending, receiving, domains, webhooks, Functions, security, limits, and self-hosting. ## What can a new account send to? By default, new accounts can send to known external addresses that previously sent authenticated inbound mail. Sending to managed `*.primitive.email` addresses, verified custom domains, and organization member emails is enabled per organization, as is broader external sending with `send_to_any_domain`. See [Sending Mail](/docs/sending). ## Do I need DNS to start? No. Every account gets a managed `*.primitive.email` subdomain with MX and outbound records already published. ## When should I add a custom domain? Add a custom domain when recipients should see your brand in the address or when existing domain mail should route into Primitive. See [Domains](/docs/domains). ## Should I use a Function or a webhook? Use a Function when you want Primitive to host the inbound handler. Use a webhook when you already have application infrastructure that should receive signed events directly. ## Are Functions cross-language? Hosted Functions execute JavaScript, so Function handler examples are TypeScript/JavaScript. Application code can use the Node.js, Python, or Go SDKs. ## Do I have to verify webhook signatures? Yes for self-hosted webhooks. Anyone who discovers a webhook URL can POST to it. The high-level SDK receive helpers verify signatures automatically when you pass the webhook secret. See [Signature Verification](/docs/signature-verification). ## What happens if my webhook fails? Primitive retries failed deliveries with backoff. The event id stays stable across retries. Return 2xx after accepting the event, including duplicate events you already processed. ## Can I replay an inbound email? Yes. Use the dashboard, `primitive emails:replay-email-webhooks --id `, or `POST /v1/emails/{id}/replay`. ## Where do agents start? Start with `/llms.txt`, then `/docs/llms.txt`, then the relevant page-level markdown mirror such as `/docs/quickstart.md` or `/docs/api.md`. ## Is PrimitiveMail the same product? PrimitiveMail was the earlier self-hosted SMTP inbox project name. Primitive is the hosted programmatic email infrastructure documented here; use the current API, MCP server, SDKs, and Functions docs for new integrations. # Functions URL: https://www.primitive.dev/docs/functions Hosted JavaScript handlers for inbound email, including handler shape, deploys, tests, logs, secrets, and limits. Primitive Functions run your JavaScript on inbound email without requiring you to host a webhook server. Deploying a Function installs the handler; binding a route to it creates an inbound endpoint of kind `function` (see [Routing and Endpoint Scope](#routing-and-endpoint-scope)). Once routed, when Primitive receives matching mail, it invokes your handler with the same email event payload used by webhooks. Function runtime snippets are TypeScript/JavaScript because hosted Functions execute JavaScript. Cross-language SDK examples for applications are shown on [SDKs](/docs/sdks), [Sending Mail](/docs/sending), [Receiving Mail](/docs/receiving), and [Signature Verification](/docs/signature-verification). ## When to Use Functions Use Functions when you want to: * process inbound mail without operating infrastructure; * call external HTTP APIs from an email handler; * reply or forward from inside the handler; * keep secrets and logs attached to the email workflow; * give an agent a narrow, deployable mailbox automation surface. Use a self-hosted webhook when you already have application infrastructure that should receive events directly. ## Recommended Start ```bash primitive functions:init my-fn cd my-fn npm install npm run build primitive functions:deploy --name my-fn --file ./dist/handler.js primitive functions:route-set --id --fallback ``` The scaffold is the recommended starting point. It pins the SDK, includes a build script, and imports the runtime client surface correctly. `primitive functions:init` is local filesystem scaffolding, so there is no REST endpoint for that step. The REST/curl equivalent starts at Function deploy, shown below. ## Handler Shape Primitive Functions run as request handlers. Primitive signs each delivery and forwards the original `Primitive-Signature` header to your Function. Verify the raw request body before parsing JSON, then return a 2xx response when the event has been accepted. ```typescript import { createPrimitiveClient, normalizeReceivedEmail, PRIMITIVE_SIGNATURE_HEADER, type EmailReceivedEvent, verifyWebhookSignature, WebhookVerificationError, } from '@primitivedotdev/sdk/api'; interface Env { // Auto-injected by Primitive on every deploy. You do not set these. PRIMITIVE_API_KEY: string; PRIMITIVE_WEBHOOK_SECRET: string; PRIMITIVE_API_BASE_URL: string; } export default { async fetch(request: Request, env: Env): Promise { const rawBody = await request.text(); const signatureHeader = request.headers.get(PRIMITIVE_SIGNATURE_HEADER) ?? ''; try { await verifyWebhookSignature({ rawBody, signatureHeader, secret: env.PRIMITIVE_WEBHOOK_SECRET, }); } catch (error) { if (error instanceof WebhookVerificationError) { return new Response('invalid signature', { status: 401 }); } throw error; } const event = JSON.parse(rawBody) as EmailReceivedEvent; if (event.event !== 'email.received') { return Response.json({ skipped: 'unhandled event type' }); } const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY }); await client.reply(normalizeReceivedEmail(event), { text: 'Got your message.', }); return Response.json({ ok: true }); }, }; ``` Use the `/api` SDK subpath inside Functions. It exposes the REST client and event normalization helpers without the `node:crypto` dependency that the root entry pulls in. File-based helpers such as `sendAttachment` load `node:fs` lazily at call time rather than importing it, so the subpath bundles cleanly for Workers-style runtimes; still avoid the file-based helpers inside Functions, where no filesystem exists. ## Build and Bundle Bundle your TypeScript to one ESM file before deploying. A neutral esbuild target works well: ```bash esbuild handler.ts \ --bundle \ --format=esm \ --target=es2022 \ --platform=neutral \ --conditions=worker,browser \ --external:'node:*' \ --sourcemap=linked \ --outfile=dist/handler.js ``` Use the scaffolded project for exact imports and build settings. The generated handler is the source of truth for the current runtime package shape. ## Deploy and Redeploy Create a Function: **CLI** ```bash primitive functions:deploy \ --name triage-agent \ --file ./dist/handler.js \ --source-map-file ./dist/handler.js.map ``` **cURL** ```bash CODE=$(cat dist/handler.js | jq -Rs .) SOURCE_MAP=$(cat dist/handler.js.map | jq -Rs .) curl -X POST https://api.primitive.dev/v1/functions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "content-type: application/json" \ -d "{\"name\":\"triage-agent\",\"code\":$CODE,\"sourceMap\":$SOURCE_MAP}" ``` Redeploy an existing Function: **CLI** ```bash primitive functions:redeploy \ --id \ --file ./dist/handler.js \ --source-map-file ./dist/handler.js.map ``` **cURL** ```bash CODE=$(cat dist/handler.js | jq -Rs .) SOURCE_MAP=$(cat dist/handler.js.map | jq -Rs .) curl -X PUT https://api.primitive.dev/v1/functions/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"code\":$CODE,\"sourceMap\":$SOURCE_MAP}" ``` Function names are immutable after creation. Use the returned `id` for redeploy, test, logs, secrets, and delete operations. On redeploy, include `sourceMap` when you want source-mapped logs for the new bundle. Omit it when you deploy without a map. REST endpoints: ```text GET /v1/functions POST /v1/functions GET /v1/functions/{id} PUT /v1/functions/{id} DELETE /v1/functions/{id} PATCH /v1/functions/{id}/tags POST /v1/functions/{id}/test GET /v1/functions/{id}/logs GET /v1/functions/{id}/secrets POST /v1/functions/{id}/secrets PUT /v1/functions/{id}/secrets/{key} DELETE /v1/functions/{id}/secrets/{key} ``` Redeploy over REST is `PUT /v1/functions/{id}`, which updates the Function and triggers a redeploy; the CLI's `functions:redeploy` wraps it. Observability and read endpoints (see [Observability](#observability) below): ```text GET /v1/functions/usage GET /v1/functions/invocation-series GET /v1/functions/routing-topology GET /v1/functions/{id}/usage GET /v1/functions/{id}/deployments GET /v1/functions/{id}/source GET /v1/functions/{id}/activity GET /v1/functions/{id}/invocations GET /v1/functions/{id}/invocations/{invocationId} GET /v1/functions/{id}/test-runs/{run_id}/trace ``` Function responses include public Function metadata such as `id`, `name`, and `deploy_status`. A `deploy_status` of `deployed` means the script is installed, not that the Function is receiving mail; the Function endpoint joins the inbound delivery loop once you bind a route to it. Deploying with a pre-built bundle (`code`) needs no extra entitlement. The managed-build path, where you upload a source directory and Primitive builds the bundle for you, requires the `functions_managed_build` entitlement on the org. ### Deploy from CI For GitHub Actions workflows, use the first-party **primitive.dev - Deploy Function** Action instead of hand-rolling curl. It is idempotent (creates the Function on first run, updates the bundle on later runs) and takes inputs for `code-path`, `files-path` (managed build), `source-map-path`, `secrets` (a JSON map), and `redeploy-on-secret-change`. Set the `expected-org-id` input in production workflows. When present, the Action checks the API key's org before any write and aborts on a mismatch, so a leaked or misconfigured key cannot deploy into the wrong organization. See [CI/CD for Functions](/docs/functions/github-actions) for an end-to-end workflow, including the CLI-based alternative. ## Routing and Endpoint Scope Creating a Function does not register an inbound endpoint on its own; a Function with no route receives no inbound mail. Bind a route explicitly — `primitive functions:route-set --id --fallback` (or `--domain `), backed by `PUT /v1/functions/{id}/route` — to create the inbound endpoint of kind `function` linked to the Function id. Once routed, Primitive routes matching inbound mail to your deployed handler and preserves the signed webhook delivery headers so your handler can verify `Primitive-Signature`. A route bound with `--fallback` creates a fallback endpoint with `domain_id = null` and empty rules. It receives accepted inbound mail only for domains that do not have an enabled endpoint scoped to that exact domain. If a domain has a scoped endpoint, that endpoint handles the domain and fallback endpoints are suppressed. Scope a Function to a specific domain from the Function Routing tab, Webhooks settings, or the route API with a domain target. ## Test Invocation Fire a real test email through the inbound path: **CLI** ```bash primitive functions:test-function --id ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/functions//test \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` The test response includes identifiers you can use to watch the invocation and inspect logs. ## Logs Function logs are attached to invocations. Use them for deploy verification and debugging. The logs API supports `limit` and `cursor`; pass the returned `next_cursor` as `cursor` to continue paging. **CLI** ```bash primitive functions:list-function-logs --id ``` **cURL** ```bash curl https://api.primitive.dev/v1/functions//logs \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Logs are operational data. Do not print API keys, webhook secrets, or customer secrets. ## Observability Beyond logs, a set of read endpoints expose deploy history, invocations, usage, and activity so you can monitor a Function without leaving the API. **Deployments.** `GET /v1/functions/{id}/deployments` returns the deploy history newest-first — each entry carries its `status`, any `deploy_error`, build timing, bundle size, and an `is_current` flag marking the live deployment. `GET /v1/functions/{id}/source` returns the source files captured for a managed-build deployment (the current deployment by default, or a specific one via `deployment_id`). **Invocations.** `GET /v1/functions/{id}/invocations` lists individual handler runs newest-first, each with its `outcome`, CPU and wall time, request method/URL, response status, and any exception name and message. It is cursor-paginated (`limit`, `cursor`) and accepts `outcome` and `since` filters. `GET /v1/functions/{id}/invocations/{invocationId}` returns one invocation in full, including the exception stack and that run's captured logs. **Usage and series.** `GET /v1/functions/{id}/usage` returns usage for one Function; `GET /v1/functions/usage` aggregates usage across every Function on the org. `GET /v1/functions/invocation-series` returns an invocation time series (defaults to the last 30 `days`, optionally scoped to one `function_id`) for charting volume over time. **Activity.** `GET /v1/functions/{id}/activity` lists the outbound effects a Function produced — `email.sent` and `email.replied` events — newest-first and cursor-paginated, with optional `type`, `email_id`, and `delivery_id` filters. Use it to confirm a handler actually sent or replied as expected. **Routing topology.** `GET /v1/functions/routing-topology` returns the org's Function routing map — which Functions are wired to which inbound domains — for a whole-org view of where mail is dispatched. **Test-run trace.** After a [test invocation](#test-invocation), `GET /v1/functions/{id}/test-runs/{run_id}/trace` returns the end-to-end trace for that run: the synthetic send, the inbound email, every webhook delivery, the Function's logs and outbound requests, and any replies — the full picture of one message moving through the pipeline. ## Secrets Function secrets are scoped per Function. Values are never returned after creation. **CLI** ```bash primitive functions:set-secret \ --id \ --key STRIPE_KEY \ --value sk_live_... \ --redeploy primitive functions:list-function-secrets --id primitive functions:delete-function-secret --id --key STRIPE_KEY ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/functions//secrets \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key":"STRIPE_KEY","value":"sk_live_..."}' curl https://api.primitive.dev/v1/functions//secrets \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl -X DELETE https://api.primitive.dev/v1/functions//secrets/STRIPE_KEY \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Secret keys must match `^[A-Z_][A-Z0-9_]*$` — uppercase letters, digits, and underscores, starting with a letter or underscore (for example `STRIPE_KEY`, `OPENAI_API_KEY`). Each value is at most **4096 bytes**. A key or value outside these bounds is rejected with a `validation_error`. `PRIMITIVE_API_KEY`, `PRIMITIVE_WEBHOOK_SECRET`, and `PRIMITIVE_API_BASE_URL` are managed reserved names that Primitive injects automatically. They cannot be set as Function secrets. Secret values are injected into the running handler environment. Add or replace a secret and redeploy so the new value reaches the active Function. Deleting a secret does not accept `--redeploy`; redeploy separately with `primitive functions:redeploy --id --file ` if the running handler must drop that binding immediately. ### Org-Level Secrets Org-level secrets are scoped to the whole organization rather than a single Function. Every Function in the org receives them, merged into its environment at deploy time, so you can store a shared credential once instead of repeating it on each Function. A Function's own secret of the same key takes precedence over the org-level value. They use the same key and value constraints as Function secrets (`^[A-Z_][A-Z0-9_]*$`, value at most 4096 bytes), and the same managed reserved names are blocked. Values are never returned after creation. ```text GET /v1/org/secrets POST /v1/org/secrets PUT /v1/org/secrets/{key} DELETE /v1/org/secrets/{key} ``` ```bash curl -X POST https://api.primitive.dev/v1/org/secrets \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key":"SHARED_API_KEY","value":"..."}' ``` Redeploy a Function after changing an org-level secret so the new value reaches its running environment. ## Idempotency and Retries Primitive retries inbound delivery when your handler does not return 2xx. The retry has the same event id. Use that id for your own dedupe keys and for outbound idempotency keys. ```typescript const attempt = event.delivery.attempt; if (attempt > 1) { console.warn('retry', { event_id: event.id, attempt }); } const result = await client.send( { from: 'summaries@your-org.primitive.email', to: env.SUMMARY_TO, subject: `Summary: ${event.email.headers.subject ?? '(no subject)'}`, bodyText: summarize(event.email.parsed.body_text ?? ''), }, { idempotencyKey: `${event.id}:summary` }, ); if (result.idempotentReplay) { console.log('replayed', result.id, 'no second copy sent'); } ``` A send from a Function without an idempotency key is still deduplicated per trigger: Primitive scopes the derived key to the Function and the inbound email that invoked it. A retry of the same delivery replays the earlier send, and a different inbound email sends again even when the message content is identical. Pass an explicit key when one invocation needs to send the same message more than once. This differs from a send made outside a Function, where a missing key is derived from the content and a fixed 5-minute window (see [Sends without a key](/docs/sending#sends-without-a-key)). A replayed Function send reports which rule applied: its response carries the `Idempotent-Replayed: true` header and an `idempotency` object with `key_source: "function_trigger"` (or `"explicit"` when you passed a key) and `window_seconds: null`. ## Loop Protection Email handlers can accidentally send mail that triggers themselves again. Add explicit guardrails: * ignore messages from your own system senders; * add marker headers or local-part tags to generated mail; * check sender and recipient domains before replying; * use idempotency keys for outbound sends. Example: ```typescript const from = event.email.headers.from?.toLowerCase() ?? ''; if (from.includes('.primitive.email') || from.includes('support@yourcompany.com')) { return Response.json({ skipped: 'system-sender' }); } ``` Loop protection is not sender authentication. The substring match above is fine here because a false positive only drops one reply. Do not reuse it to decide whether a sender is allowed to trigger an action; see the next section. ## Trusting the Sender If your handler gates a privileged action on who sent the email (an allowlisted operator address, mail from your own domain), do not match on the raw `From` header. A header like `From: "ops@yourcompany.com" ` places an allowlisted address in the display name while every authentication check runs against `evil.example`. Reply-To and the SMTP envelope sender are also fully sender-controlled. Use `isTrustedSender` from the SDK's `/api` subpath instead. It requires a passing DMARC verdict, checks that the domain DMARC evaluated equals your expected domain, and strict-parses the `From` header: ```typescript import { isTrustedSender } from '@primitivedotdev/sdk/api'; const trust = isTrustedSender(event, { domain: 'yourcompany.com', sender: 'ops@yourcompany.com', // optional exact-address requirement }); if (trust.trusted) { // run the privileged action } else if (trust.retryable) { // transient DNS failure during DMARC evaluation; return a 5xx so // inbound retry redelivers this email later return new Response('retry', { status: 503 }); } else { console.warn('untrusted sender', trust.reason, trust.auth.reasons); } ``` `trust.reason` is a stable machine-readable code (for example `dmarc-domain-mismatch`, `sender-mismatch`) naming the first check that failed. `trust.auth` is the underlying `validateEmailAuth` result (`verdict`, `confidence`, and human-readable `reasons`), included so logs can show why authentication itself passed or failed. Domains match exactly and case-insensitively; mail from a subdomain does not match unless you pass the subdomain itself. ## Validate a Deployment Send a message that should invoke the Function: **CLI** ```bash primitive send \ --from hello@.primitive.email \ --to test@.primitive.email \ --subject "hello" \ --body "trigger" \ --wait ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from":"hello@.primitive.email","to":"test@.primitive.email","subject":"hello","body_text":"trigger","wait":true}' ``` Then inspect inbound mail and outbound side effects: **CLI** ```bash primitive emails:latest --limit 10 primitive emails:get-email --id primitive sending:list-sent-emails --limit 10 primitive sending:get-sent-email --id ``` **cURL** ```bash curl "https://api.primitive.dev/v1/emails?limit=10" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/emails/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl "https://api.primitive.dev/v1/sent-emails?limit=10" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/sent-emails/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ## Limits Functions inherit the same email product limits as the organization. They also have runtime limits for wall-clock time, CPU time, 128 MiB memory, bundle size, logs, and secret count. Wall-clock and CPU are separate limits: * **Wall-clock: up to 10 minutes per invocation.** Time spent awaiting I/O, such as LLM calls or slow third-party APIs, counts against wall-clock only. A handler that awaits a two-minute inference call and then replies is fine. * **CPU: short windows, unchanged.** Actively executing JavaScript is capped separately at the platform CPU limit. The 10-minute window does not buy more compute; it buys waiting. An invocation that exceeds the 10-minute window is canceled and the delivery is marked failed with the `function_timeout` error code, then retried on the normal schedule. Retries redeliver with the same event id, so idempotent handlers dedupe cleanly. Deploy bundle size limits — both the raw and the gzip ceiling are enforced (the raw limit is checked first), and for normal bundles the gzip ceiling is the one you hit: * code bundle: **10 MB** after gzip compression (64 MB raw before compression); * source map: **15 MB** after gzip compression (64 MB raw before compression). A deploy that exceeds either ceiling is rejected with a `validation_error` naming the field (`code` or `sourceMap`) and whether the raw or gzip limit was hit. Work that fits inside the 10-minute window can run directly in the handler, including inference-length awaits. For work that can exceed it, acknowledge the email, persist the job, and process it in your own system. For external API calls, set timeouts and handle failures explicitly so retries do not create duplicate side effects. ## Make Your Agent Discoverable Once your Function is live, tell coding agents how to reach it. Add a snippet to your `llms.txt`, README, or contact page so any agent landing on your site can install the primitive-chat skill and email yours in one round-trip. No SMTP credentials; signup needs an email you can receive a verification code at, plus agreement to the Terms and Privacy Policy. No beta code. ```text ## Talk to us Email questions to ask@yourdomain.com. The address routes to an agent that knows our docs end-to-end. No email account on hand? Install the primitive-chat skill into Claude Code, Codex, Cursor, or any compatible agent: npx skills add primitivedotdev/skills That installs the Primitive skills bundle: primitive-chat provides the `primitive chat ` verb, and primitive-inbox provides a free *.primitive.email address. No SMTP creds. Signup takes an email, the verification code sent to it, and agreement to the terms. No beta code, and no leaving your terminal. ``` The `/app/functions` page exposes the same snippet as a one-click "Get llms.txt snippet" copy button. ## Related Pages * [First Function](/docs/functions/first-function): a complete scaffold-to-deploy walkthrough. * [Webhook Payload](/docs/webhook-payload): raw event shape. * [Sending Mail](/docs/sending): reply, forward, gates, and idempotency. # First Function URL: https://www.primitive.dev/docs/functions/first-function Build, deploy, test, and redeploy your first Primitive Function from the CLI scaffold. This walkthrough uses the scaffold generated by `primitive functions:init`. It is the safest way to start because the generated project already uses the expected SDK imports and bundler settings. Primitive Functions run JavaScript, so Function handler snippets are TypeScript/JavaScript by design. Application SDK examples across Node.js, Python, and Go live on [SDKs](/docs/sdks). ## 1. Scaffold ```bash primitive functions:init my-fn cd my-fn ``` This is local filesystem scaffolding. There is no REST endpoint for creating files on your machine; the REST/curl equivalent starts when you deploy the built bundle. Generated files include: * `handler.ts`; * `build.mjs`; * `package.json`; * `tsconfig.json`; * `.gitignore`; * `README.md`. ## 2. Install and Build ```bash npm install npm run build ``` The build emits a single ESM bundle at `./dist/handler.js`. ## 3. Inspect the Handler The scaffolded handler demonstrates the runtime client, handler-side signature verification, and a safe response pattern. Keep the generated imports unless the [Functions](/docs/functions) reference tells you to change them. ```typescript import { createPrimitiveClient, normalizeReceivedEmail, PRIMITIVE_SIGNATURE_HEADER, type EmailReceivedEvent, verifyWebhookSignature, WebhookVerificationError, } from '@primitivedotdev/sdk/api'; export default { async fetch( request: Request, env: { PRIMITIVE_API_KEY: string; PRIMITIVE_WEBHOOK_SECRET: string; PRIMITIVE_API_BASE_URL: string }, ) { const rawBody = await request.text(); const signatureHeader = request.headers.get(PRIMITIVE_SIGNATURE_HEADER) ?? ''; try { await verifyWebhookSignature({ rawBody, signatureHeader, secret: env.PRIMITIVE_WEBHOOK_SECRET, }); } catch (error) { if (error instanceof WebhookVerificationError) { return new Response('invalid signature', { status: 401 }); } throw error; } const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY }); const event = JSON.parse(rawBody) as EmailReceivedEvent; if (event.event !== 'email.received') { return Response.json({ ok: true, skipped: event.event }); } await client.reply(normalizeReceivedEmail(event), { text: 'Got your message.', }); return Response.json({ ok: true }); }, }; ``` Keep the generated build pipeline unless you have a concrete reason to change it. ## 4. Deploy **CLI** ```bash primitive functions:deploy \ --name my-fn \ --file ./dist/handler.js ``` **cURL** ```bash CODE=$(cat dist/handler.js | jq -Rs .) curl -X POST https://api.primitive.dev/v1/functions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"name\":\"my-fn\",\"code\":$CODE}" ``` The response includes a Function id. Store it as an environment variable for redeploys: ```bash export PRIMITIVE_FUNCTION_ID= ``` ## 5. Send a Test Email **CLI** ```bash primitive functions:test-function --id $PRIMITIVE_FUNCTION_ID ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/functions/$PRIMITIVE_FUNCTION_ID/test \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` The test travels through the same inbound path as a real email, so it verifies MX ingestion, parsing, endpoint routing, and Function execution. ## 6. Read Logs **CLI** ```bash primitive functions:list-function-logs --id $PRIMITIVE_FUNCTION_ID ``` **cURL** ```bash curl https://api.primitive.dev/v1/functions/$PRIMITIVE_FUNCTION_ID/logs \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Use logs to confirm deploys and debug handler errors. Avoid logging secrets or full customer content unless you explicitly need it. ## 7. Redeploy Edit `handler.ts`, rebuild, and redeploy: **CLI** ```bash npm run build primitive functions:redeploy \ --id $PRIMITIVE_FUNCTION_ID \ --file ./dist/handler.js ``` **cURL** ```bash CODE=$(cat dist/handler.js | jq -Rs .) curl -X PUT https://api.primitive.dev/v1/functions/$PRIMITIVE_FUNCTION_ID \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"code\":$CODE}" ``` ## 8. Add a Secret **CLI** ```bash primitive functions:set-secret \ --id $PRIMITIVE_FUNCTION_ID \ --key EXTERNAL_API_KEY \ --value value_123 \ --redeploy ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/functions/$PRIMITIVE_FUNCTION_ID/secrets \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key":"EXTERNAL_API_KEY","value":"value_123"}' ``` Read it from `env.EXTERNAL_API_KEY` inside the handler after redeploying. ## What to Build Next * route inbound messages by recipient address; * summarize email content with an LLM; * file issues in an external tracker; * reply with a status update; * discard raw content after processing. See [Functions](/docs/functions) for the full reference. # CI/CD for Functions URL: https://www.primitive.dev/docs/functions/github-actions Continuously deploy a Primitive Function from a GitHub Actions workflow using the CLI and a PRIMITIVE_API_KEY secret. Run your Function deploys from CI so every push to your default branch ships the latest handler. The CLI authenticates non-interactively from a `PRIMITIVE_API_KEY`, so the same `functions:deploy` command you run locally works inside a workflow. For the local build-and-deploy loop, start with [First Function](/docs/functions/first-function); for the full command and endpoint reference, see [Functions](/docs/functions). ## Store the API Key Add the API key as a repository (or environment) **secret** so it is masked in logs: ```bash gh secret set PRIMITIVE_API_KEY --body "$PRIMITIVE_API_KEY" ``` The CLI reads `PRIMITIVE_API_KEY` from the environment, so no `primitive login` is needed in CI. Scope the secret to a protected GitHub Environment when you want deploys gated by required reviewers. ## Recommended: Managed Build, Idempotent by Name `functions:deploy --source` uploads your project directory and lets Primitive install dependencies and bundle it server-side. Deploying by `--source` is **idempotent by name**: it creates the Function if the name is new, or redeploys it if the name already exists. That makes it safe to run on every push with no Function id to track and no local build step. The managed-build (`--source`) path requires the `functions_managed_build` entitlement on your organization; without it the deploy returns `403`. If you do not have it, build locally and deploy the pre-built bundle with `--file` instead (shown below) — that path needs no extra entitlement. ```yaml name: deploy-function on: push: branches: [main] concurrency: group: deploy-function cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22.23.4 - name: Deploy Function env: PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }} run: | npx @primitivedotdev/cli@1 functions:deploy \ --name triage-agent \ --source . \ --wait ``` `--wait` blocks until the deploy reaches a terminal state and exits non-zero on failure, so a red job means a failed deploy. The `concurrency` group keeps two overlapping merges from racing redeploys of the same Function. Pinning `@1` keeps you on non-breaking CLI updates; drop the tag to a full version (`@1.0.1`) when you want byte-stable runs. ### Seed Secrets in the Same Deploy Function secrets are scoped per Function and are never returned after creation. Drive them from GitHub secrets with `--secret-from-env`, which reads the named variable from the workflow environment and writes it before the deploy fires: ```yaml - name: Deploy Function env: PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }} EXTERNAL_API_KEY: ${{ secrets.EXTERNAL_API_KEY }} run: | npx @primitivedotdev/cli@1 functions:deploy \ --name triage-agent \ --source . \ --secret-from-env EXTERNAL_API_KEY \ --wait ``` `--secret-from-env` is repeatable. Read the value from `env.EXTERNAL_API_KEY` inside the handler. `PRIMITIVE_API_KEY`, `PRIMITIVE_WEBHOOK_SECRET`, and `PRIMITIVE_API_BASE_URL` are managed reserved names that Primitive injects automatically — never set them as Function secrets. ## Alternative: Deploy Your Own Bundle When you want to control bundling in CI, build the single ESM file yourself and deploy it with `--file`. Because `--file` create targets a name and Function names are immutable, recurring CI must **redeploy by id** rather than re-create. Create the Function once locally, capture the returned id, and store it as a repository **variable**: ```bash primitive functions:deploy --name triage-agent --file ./dist/handler.js gh variable set PRIMITIVE_FUNCTION_ID --body "" ``` Then redeploy by id on every push: ```yaml name: redeploy-function on: push: branches: [main] concurrency: group: redeploy-function cancel-in-progress: false jobs: redeploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 22.23.4 cache: npm - run: npm ci - run: npm run build - name: Redeploy Function env: PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }} run: | npx @primitivedotdev/cli@1 functions:redeploy \ --id "${{ vars.PRIMITIVE_FUNCTION_ID }}" \ --file ./dist/handler.js \ --source-map-file ./dist/handler.js.map \ --wait ``` Include `--source-map-file` when your build emits a map (`./dist/handler.js.map`) and you want source-mapped logs for the new bundle. Omit it when you deploy without a map. ### Without the CLI Any runner that can run `curl` and `jq` can redeploy by calling the REST API directly. Use this when you do not want a Node.js toolchain in the deploy job: ```bash CODE=$(cat dist/handler.js | jq -Rs .) SOURCE_MAP=$(cat dist/handler.js.map | jq -Rs .) curl -X PUT "https://api.primitive.dev/v1/functions/$PRIMITIVE_FUNCTION_ID" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"code\":$CODE,\"sourceMap\":$SOURCE_MAP}" ``` ## Verify the Deploy With `--wait`, the deploy step already blocks until the new bundle reaches a terminal state and exits non-zero on failure, so a green job confirms the deploy landed. For a deeper check, fire a test invocation through the inbound path and read the resulting logs. This needs the Function id, so it fits the `--file` path where you already keep the id in `PRIMITIVE_FUNCTION_ID`: ```yaml - name: Smoke test env: PRIMITIVE_API_KEY: ${{ secrets.PRIMITIVE_API_KEY }} run: | npx @primitivedotdev/cli@1 functions:test-function \ --id "${{ vars.PRIMITIVE_FUNCTION_ID }}" npx @primitivedotdev/cli@1 functions:list-function-logs \ --id "${{ vars.PRIMITIVE_FUNCTION_ID }}" ``` The test travels through MX ingestion, parsing, endpoint routing, and Function execution, so a green step confirms the new bundle runs end to end. ## Notes * Keep the API key in a GitHub secret so it is masked in logs; never `echo` it or pass it on a command line that gets printed. * Use a protected GitHub Environment with required reviewers when a deploy should be gated. * Prefer `--source` for CI: it is idempotent by name, needs no id, and builds server-side. Reach for `--file` only when you must own the bundling step. ## Related Pages * [First Function](/docs/functions/first-function): the local scaffold-to-deploy walkthrough. * [Functions](/docs/functions): full handler, deploy, logs, secrets, and limits reference. * [CLI](/docs/cli): install and authentication, including non-interactive `PRIMITIVE_API_KEY` use. # GraphQL API URL: https://www.primitive.dev/docs/graphql The public, read-only Primitive GraphQL endpoint, its SDL, rate-limit headers, and the GraphQL versioning and deprecation policy, including the fields deprecated today. Primitive publishes a public, read-only GraphQL surface at `https://api.primitive.dev/v1/graphql` (also served at `https://api.primitive.dev/graphql`). It needs no credentials and returns synthetic sample data shaped like the REST API, so agents can explore a typed schema and run real queries. The authenticated [REST API](/docs/api) at `https://api.primitive.dev/v1` remains the system of record. ```bash curl -s https://api.primitive.dev/v1/graphql \ -H 'content-type: application/json' \ -d '{"query":"{ apiVersion emails(first: 2) { edges { node { id subject } } pageInfo { hasNextPage endCursor } } }"}' ``` ## Schema * **Introspection** is enabled. Send the standard `__schema` query. * **SDL**: the authored schema is published as text at [`https://api.primitive.dev/graphql.graphql`](https://api.primitive.dev/graphql.graphql) (`application/graphql`). It carries every description, the `@deprecated` annotations, the `@cost` and `@rateLimit` directives, and the versioning policy. * A `GET` on the endpoint with no `query` returns a short HTML page for browsers, or the SDL when the request sends `Accept: application/graphql` or `Accept: text/plain`. The schema uses the usual conventions: Relay cursor pagination (`Connection` and `PageInfo` types), typed error result unions plus Relay-style `userErrors` on mutation payloads, an async job type you poll with `jobResult`, and a batch mutation (`bulkArchiveEmails`). ## Versioning and deprecation policy The GraphQL schema is versioned through the `apiVersion` field, which returns `v1` today. * Within a version, every change is additive and backward-compatible. New types, fields, arguments, and enum values can appear at any time, so clients should ignore fields they do not recognize. * A field scheduled for removal is marked with GraphQL's standard `@deprecated` directive. The `reason` names the replacement and the date after which the field is removed. * A deprecated field keeps working until its removal date. * Breaking changes ship only under a new version, never silently inside `v1`. ### Currently deprecated fields |Field|Replacement|Removed after| |--|--|--| |`Query.job`|`Query.jobResult`, which returns a typed `JobResult` union so an unknown job id is data rather than `null`|2026-12-01| |`Email.snippet`|`Email.bodyText`|2026-12-01| GraphQL introspection omits deprecated fields unless you ask for them. To see both fields and their reasons, pass `includeDeprecated: true` on each type: ```graphql { query: __type(name: "Query") { fields(includeDeprecated: true) { name isDeprecated deprecationReason } } email: __type(name: "Email") { fields(includeDeprecated: true) { name isDeprecated deprecationReason } } } ``` The REST API has its own policy, based on the `Deprecation` and `Sunset` headers. See [API versioning and deprecation policy](/docs/versioning). ## Limits * **Rate limit**: 60 executed queries per minute per client IP. Executed queries, whether sent by `POST` or by `GET` with a `query` parameter, return `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `RateLimit-Policy`. If the rate limiter is temporarily unavailable, the query still runs but the response carries none of these headers, so treat their absence as "no budget information" rather than "no limit". Over the limit the endpoint returns `429` with `Retry-After`. Browser clients can read these headers because they are listed in `Access-Control-Expose-Headers`. * **Body size**: request bodies are capped at 16 KB. A larger body gets a `413`. * **Mutations** must use `POST`. A mutation sent by `GET` gets a `405`. # Limits & Quotas URL: https://www.primitive.dev/docs/limits Storage quota, send and API rate limits, content retention, and the per-account limits Primitive enforces. How to read your account's effective limits from the API. Primitive enforces a small set of per-organization limits. Your account's effective limits are returned by the API, so you never have to hard-code them — read them at runtime and adapt. For plan options and pricing, see the [pricing page](/pricing). This page documents the limits themselves and how to observe them. ## Read your effective limits `GET /v1/account` returns a `limits` object alongside your plan and granted entitlements: ```bash curl https://api.primitive.dev/v1/account \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ```json { "success": true, "data": { "plan": "developer", "entitlements": ["..."], "limits": { "storage_mb": 5120, "send_per_hour": 1000, "send_per_day": 10000, "api_per_minute": 3000, "webhooks_max_global": 1, "webhooks_per_domain": false, "filters_per_domain": false, "spam_thresholds_per_domain": false } } } ``` |Field|Meaning| |--|--| |`storage_mb`|Your plan's included stored-email storage, in MB. It is an allowance, not a cap: see `GET /v1/account/storage` (below) for usage, the enforced limit, and billed overage.| |`send_per_hour` / `send_per_day`|Outbound send caps over a rolling hour and day.| |`api_per_minute`|Authenticated v1 REST API request cap per organization per minute.| |`webhooks_max_global`|Maximum endpoints across the org. `null` means unlimited.| |`webhooks_per_domain`|Whether endpoints can be scoped per domain.| |`filters_per_domain`|Whether allow/block [filters](/docs/endpoints) can be scoped per domain.| |`spam_thresholds_per_domain`|Whether spam thresholds can be set per domain.| `webhooks_max_global` is `null` when unlimited. Treat any limit as plan-dependent and read it from this endpoint rather than assuming a fixed value. ## Storage `GET /v1/account/storage` reports your stored-email usage against your plan's included storage, the limit at which mail is refused (if any), and the storage billed as overage: ```bash curl https://api.primitive.dev/v1/account/storage \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ```json { "success": true, "data": { "used_bytes": 148897792, "used_kb": 145408, "used_mb": 142, "included_mb": 5120, "limit_mb": 10240, "overage_mb": 0, "search_index_bytes": 0, "quota_mb": 5120, "percentage": 2.8, "emails_count": 5120 } } ``` |Field|Meaning| |--|--| |`included_mb`|Storage included with your plan.| |`limit_mb`|Storage at which inbound mail is refused. `null` on paid plans, which are never refused for storage.| |`overage_mb`|Storage past `included_mb`, counting your mail and your search index. Paid plans are billed for it at the storage rate; on Developer it draws on your monthly credits.| |`search_index_bytes`|Your semantic search index, billed as storage at a fixed 8 KiB per indexed chunk. It counts toward `included_mb` and `overage_mb`, never toward `limit_mb`.| |`quota_mb`, `percentage`|The included allowance and your usage as a percentage of it (can exceed 100). Kept for existing clients; `quota_mb` equals `included_mb` and is not a limit.| Stored raw email and attachments count toward storage. To free space, [discard content](/docs/receiving) you no longer need. ## Payloads (large-file) storage Primitive Payloads lets you send and receive files of any size by email (end-to-end encrypted, content-addressed), separate from the stored-email quota above. It is on by default for every organization, agent accounts included; the `payloads_enabled` entitlement in `GET /v1/account` reports whether it is on for yours. * **Transfer is free.** You never pay for download bandwidth — egress is unlimited at every plan. * **Storage is billed at `$0.03` per GB-month.** Because attachments are ephemeral by default, storage is measured over time (GB-months), so short-lived files barely count. * **Included storage per plan:** Developer 5 GB, Power 10 GB, Platinum 50 GB. On Developer the allowance is a hard cap (new uploads are refused once it is full); paid plans bill storage beyond the included amount at the rate above. ## Content retention Primitive does not delete your inbound email content on a schedule — stored raw email and attachments are retained until you remove them. When you want to delete content, you do it yourself with [content discard](/docs/receiving), which is irreversible. See the [Privacy Policy](https://www.primitive.dev/privacy) for the full data-handling and deletion terms. ## Rate limits Developer, Power, and Platinum organizations can make up to 3000 authenticated v1 API requests per minute. Organization API keys, OAuth access tokens, and connected agent keys share this budget. Agent-tier organizations have a 120-per-minute budget. Requests without valid organization credentials have a separate 120-per-minute limit per IP. The `api_per_minute` value from `GET /v1/account` reports your organization's limit. When you exceed a rate limit, the API responds with `429` (error code `rate_limited`, or `rate_limit_exceeded` on some per-resource limiters), plus `Retry-After` and `ratelimit-*` headers. Branch on the `429` status, not the code. See [Errors → Rate-limit responses](/docs/errors#rate-limit-responses) for the exact headers and the recommended back-off. Outbound sends are additionally bounded by `send_per_hour` / `send_per_day`. These protect shared outbound IP reputation; if you have a legitimate higher-volume use case, contact support. ## Entitlement-gated features Some capabilities are turned on per organization and appear in the `entitlements` array from `GET /v1/account`. If a feature is not enabled, the relevant endpoint returns `403`. The exact code varies by endpoint: the x402 and routing endpoints return `feature_disabled` and include the missing entitlement and a `docs_url` in `error.details`, while other gated endpoints return `feature_disabled` without details or the generic `forbidden` error. Examples include [x402 payments](/docs/collecting-payments) (invite-only) and per-domain filters and routes on paid plans. ## Related Pages * [Errors](/docs/errors): the rate-limit envelope and headers. * [Pricing](/pricing): plan options. * [Sending Mail](/docs/sending): outbound caps and idempotency. # Mail Relay URL: https://www.primitive.dev/docs/mail-relay Keep Google Workspace as your mailbox provider and get every message in Primitive too, with webhooks, Functions and the API. A mail relay domain keeps your existing mailbox provider. Your team keeps using Gmail as before, and Primitive gets a copy of the domain's mail: incoming mail is stored and triggers webhooks and Functions like any other domain, and mail your team sends from Gmail is recorded in your outbox. Mail relay domains currently support Google Workspace. They are available to accounts with mail relay enabled. ## How mail flows * **Inbound:** the domain's MX points at a Primitive mail relay. The relay forwards each message to your Google Workspace mailboxes and, at the same time, stores a copy in Primitive. * **Outbound:** Google Workspace routes outgoing mail through the relay. The relay checks the message really comes from your Workspace, sends it on, and records it in your Primitive outbox with its delivery result. * **Inside Workspace:** by default, mail between users of the same Workspace stays inside Google and is not stored in Primitive. An optional routing rule (below) sends it through the relay as well. ## Setup Add the domain on the Domains page and choose **Keep my existing mailbox provider (mail relay)**. The domain gets its own setup checklist. Every item is required: the relay needs all of them to keep mail flowing, and the domain only verifies once they all pass in the same check. The domain card shows the mailbox provider as a badge, with its size limit against Primitive's. Primitive detects the provider when you add the domain: from the MX while it still points at your provider, otherwise from your SPF record's include (for Google Workspace, `include:_spf.google.com`) or Google Workspace's DKIM key. The relay forwards only to Google Workspace today; a domain on any other provider, or one where nothing in DNS names a provider, is shown as not supported yet. Do the Google Workspace steps first. Switch MX last: if MX changes before Google is ready, Gmail sees all incoming mail arriving from the relay's address and may treat it as spam. 1. **Google DKIM.** In the Google Admin console, open Apps, Google Workspace, Gmail, Authenticate email. Generate a record, publish it, then select Start authentication. 2. **Inbound gateway.** In Gmail, Spam, phishing and malware, Inbound gateway, add the relay addresses shown in the checklist, select "Automatically detect external IP", and require TLS. 3. **Outbound route.** In Gmail, Hosts, add the relay hostname from the checklist on port 25 with TLS required. Then add a Routing rule for outbound mail whose envelope sender matches your domain, routed to that host. 4. **Catch-all.** Create a group to receive mail for addresses that have no mailbox, and a routing rule that sends unknown recipients to it, as described in the checklist. This stops Google bouncing mail that the relay already accepted. 5. **DNS.** Publish the records the checklist shows: the MX to the relay, the ownership TXT, one SPF record, Primitive's DKIM records, DMARC, and TLS reporting. The checklist checks every DNS record and the Google DKIM key for you. The Google Admin steps cannot be seen from outside Google, so you confirm them yourself. ### SPF A domain must have exactly one SPF record. The checklist shows the record to publish; it authorises the relay, Google and Primitive. If you send from other services, add their mechanisms to that same record rather than publishing a second one. Two SPF records (or two DMARC records) make receivers treat both as broken, and the checklist reports them. ### Optional: store mail between your own users In Gmail, Routing, add a rule for **Internal - Sending** messages whose envelope sender matches your domain, with **Change route** set to the relay host from the outbound route. Internal mail is then stored in Primitive like other mail: in your outbox for the sender and as an incoming message for the recipient. The trade-off: internal mail then depends on the relay. If the relay cannot be reached, Google retries and that mail is delayed. The relay accepts internal mail only when it carries your Google DKIM signature, and whether Google signs mail routed this way has not been confirmed yet; if it does not, internal mail on the rule bounces. Try the rule on one organizational unit first. Send between two of its users and check that the recipient gets the message once with no bounce, and that Primitive shows it in your outbox and as an incoming message. If the test bounces, delete the rule, which returns internal mail to staying inside Google. Widen the rule only after it passes. ## What you get * **Inbound mail** is stored and delivered to your webhooks and Functions as usual. The email object and the `email.received` event carry a `relay` object, for example `{ "hostname": "relay-mx.example.com", "via": "mail_relay" }`, and `relay: null` for mail that did not come through a relay. * **Delivery to Google Workspace** is shown per recipient: `GET /v1/emails/{id}` returns `relay.delivery`, a list of `{ recipient, status, smtp_code, enhanced_status_code, smtp_response, at }` with `status` one of `delivered`, `deferred` or `bounced`, and the dashboard shows the same on the email page, with a "Not delivered to Google Workspace" badge naming the reason when Google did not take it for any recipient. Each refusal is explained in plain words from Google's SMTP answer (no such mailbox, mailbox full, refused under Google's security or spam policy, or larger than Google accepts), with the raw answer alongside. A message the relay stopped retrying is shown as a delivery timeout, not as a refusal by Google. Google only allows 25 MB of attachments (50 MB per message, encoding included) while Primitive allows 50 MiB on your account, so a large message can be stored in Primitive but not delivered to Google. The message stays stored in Primitive either way. The `email.received` webhook fires when the message reaches Primitive, before the Google result is known, so it does not include `delivery`, and no separate webhook fires for the Google result. * **Relayed mail Primitive refused** (for example over a plan limit, or a protocol violation) is not stored in Primitive. Delivery to the mailbox is separate and usually still succeeds. The domain's Relay setup section shows how many messages Primitive refused in the last 7 days, by reason. * **Mail sent from Gmail** appears in your outbox, threaded with the conversation, with its delivery result for each recipient. * **Sending through the API** from the domain works as for any verified domain. Mail sent to an address on a relay domain always goes through the relay, so it reaches the mailbox as well as Primitive. ## While setup is in progress Until the domain verifies, the relay answers mail for it with a temporary failure, so senders retry and nothing bounces. Mail sent before verification arrives once the domain verifies, within the senders' normal retry window. ## Limits * The relay accepts messages up to 50 MiB, in both directions, and stores them in Primitive at that size. * Mail sent from Gmail is recorded in the outbox; it does not trigger webhooks or Functions. * Addresses with quoted or non-ASCII local parts are delivered to Google Workspace but not stored in Primitive. ## Removing the relay Point the MX back at Google (`smtp.google.com`, or the records Google gave you), remove the outbound routing rule and host in the Google Admin console, then remove the relay addresses from the inbound gateway. Mail already accepted by the relay is still delivered. # Managed Domain URL: https://www.primitive.dev/docs/managed-inbox How the auto-issued *.primitive.email domain works and when to use it instead of a custom domain. Every Primitive organization gets a managed `*.primitive.email` subdomain at signup. It is ready for inbound and outbound mail without user-managed DNS. Example: ```text brave-otter.primitive.email ``` You can receive at any local-part under that domain, such as `support@brave-otter.primitive.email` or `agent+triage@brave-otter.primitive.email`. ## What Primitive Manages Primitive owns and operates the parent DNS zone. For your managed subdomain, Primitive publishes the records needed for: * inbound MX delivery; * outbound DKIM signing; * SPF alignment for Primitive-managed sends; * DMARC reporting policy. Because the records are managed by Primitive, verification is immediate. ## When to Use It Use the managed domain for: * first integrations and quickstarts; * agent inboxes; * internal automation; * testing send and receive flows; * workflows where Primitive-branded mail is acceptable. Use a custom domain when recipients should see your brand in the address, or when you need mail to route for an existing domain. ## Sending from the Managed Domain The CLI can auto-select a valid `from` address on the managed domain: **CLI** ```bash primitive send --to alice@example.com --body "hello" --wait ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"from":"agent@brave-otter.primitive.email","to":"alice@example.com","subject":"Hello","body_text":"hello","wait":true}' ``` In REST, pass an address on the managed domain explicitly: ```json { "from": "agent@brave-otter.primitive.email", "to": "alice@example.com", "subject": "Hello", "body_text": "Hi." } ``` ## Receiving on the Managed Domain Inbound mail to the managed domain is accepted by Primitive MX and delivered to your active endpoints or Functions. If no endpoint is active, the email is still stored and can be retrieved through the REST API. ## Subaddressing Use plus tags for routing hints: ```text agent+github@brave-otter.primitive.email agent+billing@brave-otter.primitive.email ``` The parsed payload preserves recipient addresses so your handler can branch on the local part. ## Moving to a Custom Domain When you are ready for branded mail, add a domain in the dashboard, publish the DNS records, and wait for verification. The managed domain remains available after custom domains are added. See [Domains](/docs/domains) for record details. # Primitive Memories URL: https://www.primitive.dev/docs/memories Store durable JSON key-value state for agents and Functions with org and function scope, TTLs, version checks, search, and usage billing. Primitive Memories are durable JSON key-value records for agent state. Use them when an agent or Function needs to remember compact structured state across turns, emails, retries, or process restarts without bringing its own database. Common uses: * Remember the last processed email or thread id. * Persist workflow checkpoints such as `{ "step": 2 }`. * Share small org-scoped state across tools or agents. * Keep Function-local state scoped by immutable Function id. * Coordinate retries with `if_absent` or `if_version`. Memories are not a replacement for bulk storage or document search. Values are JSON, keys are caller-defined strings, and search is by key prefix. ## Scopes Every memory belongs to exactly one organization. Within that organization it is resolved under one of two scopes: |Scope|Use it for|How to select it| |--|--|--| |Org|State shared by the organization.|Default for normal API, SDK, and CLI calls.| |Function|State private to one Primitive Function.|Pass a Function id UUID, not a function name.| When a request is already authenticated as a Function, Primitive uses that Function id automatically. API and SDK callers can also pass the `x-primitive-function-id` header or explicit function scope. CLI users can pass `--function `. Function names are mutable and are never accepted as scope identifiers. ## CLI Use the `primitive memories` commands for terminal and agent workflows: ```bash primitive memories set thread:latest '{"email_id":"em_123"}' primitive memories get thread:latest primitive memories search thread: --metadata-only primitive memories delete thread:latest ``` Values must be valid JSON. Strings must be quoted as JSON strings: ```bash primitive memories set greeting '"hello"' ``` Function-scoped memories: ```bash primitive memories set state '{"step":2}' --function primitive memories get state --function primitive memories search state: --function primitive memories delete state --function ``` Read a value from a file: ```bash primitive memories set agent:state --value-file ./state.json ``` ## REST API All endpoints live under `https://api.primitive.dev/v1` and use the standard Primitive bearer auth header. Set a memory: ```bash curl -X PUT https://api.primitive.dev/v1/memories \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key":"thread:latest","value":{"email_id":"em_123"}}' ``` Get a memory: ```bash curl "https://api.primitive.dev/v1/memories?key=thread:latest" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Search by key prefix: ```bash curl "https://api.primitive.dev/v1/memories/search?prefix=thread:&include_value=false" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Delete a memory: ```bash curl -X DELETE "https://api.primitive.dev/v1/memories?key=thread:latest" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Function scope over REST: ```bash curl -X PUT https://api.primitive.dev/v1/memories \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "key": "state", "value": { "step": 2 }, "scope": { "type": "function", "id": "" } }' ``` For get, delete, and search, use query scope: ```bash curl "https://api.primitive.dev/v1/memories?key=state&scope_type=function&scope_id=" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ## SDKs In Node.js, use the high-level client: ```ts import { createPrimitiveClient } from '@primitivedotdev/sdk/api'; const client = createPrimitiveClient({ apiKey: process.env.PRIMITIVE_API_KEY, }); await client.memories.set({ key: 'thread:latest', value: { email_id: 'em_123' }, }); const memory = await client.memories.get('thread:latest'); const page = await client.memories.search({ prefix: 'thread:', includeValue: false, }); await client.memories.delete('thread:latest'); ``` Function-scoped writes pass `scope: { type: 'function', id: '' }`. Function-scoped reads, deletes, and searches pass `scope_type: 'function'` and `scope_id: ''`. `client.memories.search` is key prefix search, not free-text or semantic search. Use `client.semanticSearch(...)` for mail search. The generated SDK clients still expose lower-level OpenAPI operation ids `setMemory`, `getMemory`, `deleteMemory`, and `searchMemories` for callers that need the exact REST operation shape. ## JSON and Limits Values can be any valid JSON value: * object * array * string * number * boolean * `null` Limits: |Field|Limit| |--|--| |Key|512 UTF-8 bytes| |Value|65,536 UTF-8 bytes after JSON serialization| |Search page size|1 to 100, default 50| |TTL|1 second to 31,536,000 seconds| The API rejects values that do not serialize as JSON. ## Metadata and Versions Memory records include metadata agents can use for coordination: |Field|Meaning| |--|--| |`id`|Stable memory record id.| |`key`|Caller-defined key.| |`scope`|Resolved org or function scope.| |`value`|JSON value, omitted when search uses `include_value=false`.| |`version`|Monotonic version serialized as a string.| |`created_at` / `updated_at`|Creation and last write timestamps.| |`last_read_at`|Last read timestamp, or `null`.| |`read_count` / `write_count`|Counters serialized as strings.| |`expires_at`|Expiration timestamp, or `null`.| |`created_by` / `updated_by`|Actor that created or last updated the record.| Use `version` with compare-and-set when multiple workers may update the same key. ## TTLs Set an expiration with `ttl_seconds` or `expires_at`: ```bash curl -X PUT https://api.primitive.dev/v1/memories \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"key":"lock:agent","value":{"owner":"agent-1"},"ttl_seconds":300}' ``` On update, existing TTL is preserved unless you pass `ttl_seconds`, `expires_at`, or `clear_ttl`. ## Version Checks Create only if the key is absent: ```bash primitive memories set lock '{"owner":"agent-1"}' --if-absent ``` Update only if the current version matches: ```bash primitive memories set state '{"step":3}' --if-version 2 ``` Delete only if the current version matches: ```bash primitive memories delete state --if-version 3 ``` Stale version checks return `409 memory_conflict`. ## Search Search lists active memories in lexicographic key order by prefix: ```bash primitive memories search thread: --limit 100 --metadata-only ``` REST search parameters: |Parameter|Description| |--|--| |`prefix`|Key prefix to match. Omit to list all active memories in scope.| |`cursor`|Continuation cursor from `meta.cursor`.| |`limit`|Page size, 1 to 100, default 50.| |`include_value`|`true` or `false`, default `true`.| |`updated_after`|Only include memories updated at or after this timestamp.| |`updated_before`|Only include memories updated at or before this timestamp.| |`scope_type` / `scope_id`|Optional scope selection.| Use `include_value=false` when an agent only needs keys and metadata. ## Billing Primitive Memories are metered through the same usage billing system as the rest of Primitive: * `memory.storage.gb_day` for stored memory size over time. * `memory.read` for get and search operations. * `memory.write` for set and delete operations. On Developer, billable actions draw from the monthly usage credit grant and can return `402 developer_usage_credit_exhausted` when credits are exhausted. Paid plans accrue usage overage instead of stopping at monthly credits. See [Pricing](/pricing) and [Limits & Quotas](/docs/limits). ## Related Pages * [CLI](/docs/cli): `primitive memories` terminal commands. * [SDKs](/docs/sdks): generated client imports and examples. * [REST API](/docs/api): OpenAPI discovery, auth, and envelope shape. * [Functions](/docs/functions): hosted JavaScript handlers that can use function-scoped Memories. # Pay an x402 Request over Email URL: https://www.primitive.dev/docs/paying-x402-over-email Pay an x402 payment request you received over email. Fund a USDC-on-Base wallet (no gas needed), then settle the request with one command from the inbound email, or sign and reply with the SDK. This is the **payer** side of email-native [x402](https://www.x402.org/). You received an email that carries an x402 payment request and you want to pay it. For the **payee** side (issuing a request and collecting funds) see [Collecting Payments](/docs/collecting-payments), and for the full protocol and trust model see [x402 over Email](/docs/x402-over-email). When another agent charges you over email, the request arrives as a normal email thread carrying a structured `interaction.json` `challenge` part. To pay it, you sign an EIP-3009 `TransferWithAuthorization` bound to that specific challenge and reply on the same thread. Primitive verifies your reply, settles on-chain through a facilitator, and the settlement receipt lands back on the thread. Settlement is non-custodial: funds move directly from your wallet to the payee, and Primitive never holds the money or your key. Amounts are always in **token base units**. USDC has 6 decimals, so `"10000"` is `0.01` USDC. Supported networks are `base` (mainnet) and `base-sepolia` (testnet); the asset is that network's USDC. ## Prerequisites * **A funded USDC-on-Base wallet.** Hold enough USDC on the challenge's network (`base` or `base-sepolia`) to cover the requested amount. The wallet's private key signs the authorization locally and is never sent to Primitive. * **No ETH or gas.** The authorization is a gasless EIP-3009 `TransferWithAuthorization`: you sign an off-chain message, and the facilitator submits and pays the gas to move the funds on-chain. You do not need ETH in the wallet, and you never broadcast a transaction yourself. * **The wallet key in your environment.** The CLI reads the signing key from the `PRIMITIVE_X402_PRIVATE_KEY` environment variable (or `--private-key`). The SDKs accept a signer built from the key directly. * **A Primitive account.** You authenticate to the API with your `prim_` API key (or OAuth access token) the same as any other call. See [Quickstart](/docs/quickstart). * **The payer capability enabled.** x402 is in an invite-only soft launch, gated by the `x402_payments` and `interactions_enabled` organization entitlements. If a payment call returns `feature_disabled`, the feature is not enabled for your organization yet. To request access, contact [support](https://www.primitive.dev/contact) or email `dev@agent.primitive.dev`. Pin your payer identity to its own domain or subdomain. A payment is only trusted when it arrives over a DKIM-authenticated email from the exact address the challenge was sent to. See [the pinned-payer model](/docs/x402-over-email#the-pinned-payer-model). ### How to get USDC on Base Funding the wallet happens entirely outside Primitive. Primitive is non-custodial: it never holds your funds or your key, so the wallet and the USDC in it are yours alone. Common options for getting USDC onto Base include: * **Withdraw from an exchange.** Buy USDC on a major exchange that supports Base withdrawals, then withdraw it directly to your Base address. Coinbase supports Base natively, and other major exchanges increasingly support Base withdrawals. * **Use a fiat on-ramp that delivers to Base.** On-ramp providers such as Coinbase Onramp let you buy USDC with fiat and receive it on Base. * **Bridge from another chain.** If you already hold USDC on another chain, bridge it to Base using the official [Base bridge](https://bridge.base.org/) or another reputable bridge. Make sure you end up holding **native USDC** on Base, issued by Circle at contract `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`. Base also has a legacy bridged token, **USDbC**, at a different address. x402 payments use native USDC, so USDbC will not settle a challenge; if you bridged in older funds, confirm you hold native USDC at the address above. ## Funding a wallet If you do not already have a wallet, create one and fund it with USDC. For experimenting, use the `base-sepolia` testnet so no real funds are at risk: ```bash # Create a fresh key with any standard tool, for example: cast wallet new # from Foundry; prints an address and private key export PRIMITIVE_X402_PRIVATE_KEY=0x ``` Fund the wallet with USDC from Circle's testnet faucet on `base-sepolia`. You do **not** need test ETH to pay: the facilitator pays the gas. For `base` mainnet, fund a real wallet with USDC and keep the key in a secret manager, never in shell history. ## The end-to-end flow 1. **You receive a request.** An email arrives on a thread carrying the x402 `challenge`. It identifies the amount, the network, and the payee. With an inbound [Function](/docs/functions), webhook, or `primitive emails:latest`, you have the inbound email's id. An inbox may hold several past "Payment request" emails. Pay the **most recent unexpired** one: check the challenge's `expires_at` and skip any request whose deadline has passed, since an expired challenge can no longer settle. 2. **You pay it with one command.** Save the challenge — the structured x402 `challenge` part carried in the inbound email — to a file, then point the CLI at it and at the inbound email's id. The CLI signs the bound authorization locally and replies on the same thread with the signed payment: ```bash export PRIMITIVE_X402_PRIVATE_KEY=0x primitive payments pay-email \ --challenge-file challenge.json \ --in-reply-to \ --wait ``` If you omit `--challenge-file` (and `--challenge`), the CLI derives the challenge for you from the inbound email's structured attachment. `--in-reply-to` identifies the inbound payment request and sends your payment from the pinned payer address automatically so DKIM aligns. The outgoing send is intentionally not threaded under the challenge; the payment associates by the interaction id instead. `--wait` blocks until the receiving mail server accepts the reply; omit it to return as soon as Primitive accepts the reply for delivery. Settlement is asynchronous either way (see below); `--wait` does not wait for on-chain settlement. 3. **Settlement.** Settlement happens asynchronously after your reply is delivered: Primitive verifies your reply's DKIM signature and the signed payment against the recorded challenge, then settles on-chain through a facilitator. The `pay-email` command sends the signed reply; it does not settle synchronously and does not return a `settle_tx`. 4. **A receipt confirms it.** A follow-up **receipt** email lands back on the same thread carrying the on-chain `settle_tx`: ```json { "step": "receipt", "payload": { "settle_tx": "0x...", "status": "settled" } } ``` You can independently verify the settlement on a block explorer: use `https://basescan.org/tx/` for `base`, or `https://sepolia.basescan.org/tx/` for `base-sepolia`. Use the explorer web page (or a current BaseScan API version); the deprecated V1 API returns a confusing `NOTOK` response. If settlement cannot complete, a **reject** step comes back instead, carrying a `reason`. Read the thread (or your inbound Function) for the terminal step. ## The SDK equivalent (sign and send) For non-CLI payers, the SDK splits the work into a local signing step and a send step, so you control how the reply is dispatched. `payEmailChallenge` takes the challenge object, derives the challenge-bound nonce, signs the EIP-3009 authorization **locally**, and returns the canonical `interaction.json` payment-step bytes. It signs only; it does not send. You then reply on the same thread, from the pinned payer address, attaching those bytes as an `interaction.json` part (`application/json`). Sending nothing settles nothing. **Node.js** ```typescript import { createX402Client, type X402EmailChallenge } from '@primitivedotdev/sdk/x402'; import { privateKeyToAccount } from 'viem/accounts'; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); const signer = privateKeyToAccount(process.env.PRIMITIVE_X402_PRIVATE_KEY as `0x${string}`); // `challenge` is the x402 challenge carried by the inbound email's // `interaction.json` part (the same shape the payee's createEmailChallenge returns). const challenge: X402EmailChallenge = JSON.parse(receivedChallengeJson); const { json } = await x402.payEmailChallenge(challenge, { signer }); // `json` is the signed interaction.json payment step. Reply on the SAME thread, // FROM the pinned payer address, attaching `json` as an application/json part // named interaction.json. Use the inbound SDK client to reply in-thread. ``` **Python** ```python import os from primitive.x402 import create_x402_client, PrivateKeySigner client = create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"]) signer = PrivateKeySigner(os.environ["PRIMITIVE_X402_PRIVATE_KEY"]) # `challenge` is the x402 challenge carried by the inbound email. payment_step = client.pay_email_challenge(challenge, signer=signer) # Reply on the same thread, from the pinned payer address, attaching the # returned payment step as an interaction.json (application/json) part. ``` **Go** ```go x402 := primitive.NewX402Client(primitive.X402ClientOptions{ APIKey: os.Getenv("PRIMITIVE_API_KEY"), }) signer, err := primitive.NewPrivateKeySigner(os.Getenv("PRIMITIVE_X402_PRIVATE_KEY")) if err != nil { log.Fatal(err) } // `challenge` is the x402 challenge carried by the inbound email. paymentStep, err := x402.PayEmailChallenge(challenge, signer) if err != nil { log.Fatal(err) } // Reply on the same thread, from the pinned payer address, attaching // paymentStep as an interaction.json (application/json) part. ``` The SDK computes the EIP-3009 validity window for you, so you do not hand-set `validAfter` / `validBefore`. See [the validity window](/docs/x402-over-email#the-validity-window) for the accepted band. ## Declining a request You are never obligated to pay. To refuse a charge, reply with a `decline` step (carrying an optional `reason`) instead of a `payment`. A decline is a clean, terminal "no" on the thread, not an error. See [Declines and rejects](/docs/x402-over-email#declines-and-rejects). ## Troubleshooting * **`identity_mismatch`.** Your payment reply was DKIM-authenticated, but the signing domain did not align to the address the challenge was sent to. Send the payment from the exact pinned payer address, on its own domain or a true subdomain. A sibling subdomain of a shared parent does not align. * **No usable trust (`trust_level`).** The reply had no passing DKIM signature over the `From` and `Content-Type` headers, so the sender could not be authenticated. Confirm DKIM is published and aligned for your sending domain. * **`settlement_failed`.** The on-chain settle did not complete, most often because the paying wallet did not hold enough USDC on that network. Fund the wallet with USDC and have the payee issue a fresh challenge. Remember you need USDC, not ETH. * **`challenge_expired`.** The payment arrived after the challenge expired and can no longer settle. The payee issues a new challenge; ask for a longer `expires_in` if you need more time. * **Verification failure.** The signed payment did not match the recorded challenge. Confirm you signed for the exact wallet, network, amount, and challenge you are replying to. * **Retries are safe.** Email is delivered at-least-once and the challenge-bound nonce can settle only once, so a duplicate or redelivered payment reply settles at most once. You never get charged twice. ## Endpoint reference The payer flow is driven by the CLI and SDKs above rather than a payer-specific endpoint: signing happens locally and the payment travels as a reply on the email thread. For the underlying email-native endpoints, see the [endpoint reference in x402 over Email](/docs/x402-over-email#endpoint-reference). For full request and response schemas see the [API reference](/docs/api), and for installation see the [SDKs](/docs/sdks) and [CLI](/docs/cli) pages. # Quickstart URL: https://www.primitive.dev/docs/quickstart Sign up, scaffold a Function, send mail, receive mail, and reply using the managed *.primitive.email domain. This path gets a new account from zero to sending, receiving, and replying without touching DNS. It uses the managed `*.primitive.email` subdomain and the `primitive` CLI. ## 1. Create an Account Sign up at [primitive.dev](https://www.primitive.dev). Your organization receives a managed subdomain like `brave-otter.primitive.email`. The managed subdomain has MX and outbound records published automatically. You can receive inbound mail and send from addresses on that subdomain immediately. ## 2. Install And Login With The CLI The CLI uses OAuth, so you do not need to create or paste an API key for terminal use. ```bash brew install primitivedotdev/tap/primitive # or: npm install -g primitive primitive login primitive whoami ``` If you cannot install globally, run commands with `npx primitive@latest`. Homebrew installs the required Node.js runtime automatically; npm and npx require Node.js 22 or newer. The CLI package and runtime SDK are separate: use `primitive` (also published as the scoped `@primitivedotdev/cli`) for the `primitive` binary, and use `@primitivedotdev/sdk` inside application code. ## 3. Create Optional cURL Credentials If you use the cURL examples in this guide, open **Settings -> API keys** and create an API key. Store it as `PRIMITIVE_API_KEY`. CLI-only users can skip this. For webhook verification, open **Settings -> Webhooks** and copy the signing secret as `PRIMITIVE_WEBHOOK_SECRET`. ```bash export PRIMITIVE_API_KEY=prim_... export PRIMITIVE_WEBHOOK_SECRET= ``` ## 4. Verify API Access **CLI** ```bash primitive whoami ``` **cURL** ```bash curl https://api.primitive.dev/v1/account \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ## 5. Scaffold a Function Functions are the fastest receive path because Primitive hosts the handler and wires it to inbound mail. ```bash primitive functions:init my-fn cd my-fn npm install npm run build ``` This step is local project generation. There is no REST endpoint for scaffolding files on your machine; use REST/curl at deploy time. The scaffold contains `handler.ts`, `build.mjs`, `package.json`, `tsconfig.json`, and a README. It imports the runtime client correctly and uses the bundler settings expected by Primitive Functions. ## 6. Deploy the Function **CLI** ```bash primitive functions:deploy \ --name my-fn \ --file ./dist/handler.js ``` **cURL** ```bash CODE=$(cat dist/handler.js | jq -Rs .) curl -X POST https://api.primitive.dev/v1/functions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"name\":\"my-fn\",\"code\":$CODE}" ``` Deploy creates the Function, but inbound mail does not reach it until you bind a route. Save the returned Function id, then register the inbound endpoint explicitly: **CLI** ```bash primitive functions:route-set --id --fallback ``` **cURL** ```bash curl -X PUT https://api.primitive.dev/v1/functions//route \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"target":{"kind":"fallback"}}' ``` `--fallback` binds the Function to any active domain without a scoped binding of its own, which covers your managed subdomain. Keep the Function id for later redeploys. ## 7. Send an Email Use the CLI first because it auto-resolves a usable `from` address on your managed domain. **CLI** ```bash primitive send \ --to you@example.com \ --body "Hello from Primitive" \ --wait ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "hello@your-org.primitive.email", "to": "you@example.com", "subject": "Hello from Primitive", "body_text": "It works.", "wait": true }' ``` The `--wait` flag blocks until Primitive has a delivery result from the receiving side. ## 8. Understand First-Send Gates Each recipient is checked against a set of entitlement gates. Out of the box, new accounts can send to: * any address on a domain you have added and verified ownership of (no entitlement needed: verifying a domain lets you send both *from* it and *to* it); * external addresses that have already sent you authenticated mail (replying to known addresses is granted by default). Other recipient gates exist but require entitlements that are not granted to a fresh account by default: * addresses on `*.primitive.email` (requires `send_to_primitive_managed_domains`); * addresses at domains on your account whose ownership is *not* verified (requires `send_to_confirmed_domains`); * email addresses that belong to members of your Primitive organization (requires `send_to_org_member_emails`); * addresses on domains that have opted in to receive from agents via a DNS `_agents` record (requires `send_to_opted_in_domains`); * any domain, only after support enables broader outbound sending for your organization. If a first send to Gmail, Outlook, Fastmail, or another external address returns `403 recipient_not_allowed`, inspect the returned `gates[]` array — each gate carries its own `fix` object (read `gates[].fix.action`), though some denials have no automatic fix. See [Sending Mail](/docs/sending) for the full model. ## 9. Receive an Email Send a test message to any address at your managed subdomain, for example `hi@your-org.primitive.email`. Primitive receives it through MX, parses it, stores it, signs the event, and invokes your Function. You can also read it from the CLI: **CLI** ```bash primitive emails:latest --limit 5 primitive emails:get-email --id ``` **cURL** ```bash curl "https://api.primitive.dev/v1/emails?limit=5" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/emails/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ## 10. Reply Reply to an inbound message with threading derived from the original email: **CLI** ```bash primitive sending:reply-to-email \ --id \ --body-text "Got it." ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/emails//reply \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"body_text":"Got it."}' ``` In SDK code, use `client.reply(email, ...)` in Node.js or Python and `client.Reply(...)` in Go. Primitive derives the recipient, `Re:` subject, `In-Reply-To`, and `References` headers. ## Go Deeper * [Functions](/docs/functions): handler shape, deploys, tests, logs, secrets, and limits. * [Sending Mail](/docs/sending): sends, replies, forwards, idempotency, and gates. * [Receiving Mail](/docs/receiving): webhooks, retries, replays, and storage. * [SDKs](/docs/sdks): Node.js, Python, and Go examples. * [Collecting Payments](/docs/collecting-payments): charge and pay USDC with x402 (invite-only soft launch). # Receiving Mail URL: https://www.primitive.dev/docs/receiving Inbound MX flow, webhook delivery, signature verification, retries, replays, endpoint rules, and stored email access. Primitive receives inbound mail through MX, parses it, stores it, and delivers it to your code as a signed JSON event. You can receive through a webhook endpoint you host, a Primitive Function hosted by Primitive, or the REST API after the message is stored. ## Inbound Flow ```text external sender | v Primitive MX | v parse + authenticate + store | +--> signed webhook endpoint +--> hosted Primitive Function +--> REST API resource ``` Every inbound event has a stable event id. Retries reuse that id, so your handler can be idempotent. ## Webhook Endpoint Set a webhook URL in the dashboard or via the API. The endpoint must be HTTPS and publicly reachable. Primitive sends `application/json` with a `Primitive-Signature` header. Verify the signature before trusting the payload. **Node.js** ```typescript import primitive from '@primitivedotdev/sdk'; export async function POST(req: Request) { const email = await primitive.receive(req, { secret: process.env.PRIMITIVE_WEBHOOK_SECRET!, }); console.log(email.sender.address, email.subject, email.text); return Response.json({ ok: true }); } ``` **Python** ```python import os import primitive from fastapi import FastAPI, Request app = FastAPI() @app.post('/webhooks/email') async def handle(request: Request): body = await request.body() email = primitive.receive( body=body, headers=dict(request.headers), secret=os.environ['PRIMITIVE_WEBHOOK_SECRET'], ) print(email.sender.address, email.subject, email.text) return {'ok': True} ``` **Go** ```go func handle(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) email, err := primitive.Receive(primitive.HandleWebhookOptions{ Body: body, Headers: r.Header, Secret: os.Getenv("PRIMITIVE_WEBHOOK_SECRET"), }) if err != nil { http.Error(w, "invalid signature", http.StatusUnauthorized) return } log.Println(email.Sender.Address, email.Subject) w.WriteHeader(http.StatusOK) } ``` Return any 2xx response to acknowledge delivery. Non-2xx responses and network failures are retried. ## Primitive Functions Functions are hosted receive handlers. Deploy a JavaScript handler, then bind a route to it with `primitive functions:route-set` (or `PUT /v1/functions/{id}/route`) to create the inbound endpoint. Use Functions when you do not want to run a public webhook server. See [Functions](/docs/functions) for the handler shape, deploy flow, logs, and secrets. ## Signature Verification Every webhook is signed. You must verify the exact raw request body, not a re-serialized JSON object. The high-level `primitive.receive(...)` helper verifies signatures automatically. Lower-level helpers are documented in [Signature Verification](/docs/signature-verification). ## Retries Primitive retries webhook delivery when your endpoint does not return a 2xx response. Don't rely on `delivery.attempt` to tell a first delivery from a retry — dedupe on the top-level event `id` instead. See [Webhook Payload](/docs/webhook-payload) for the field's semantics. For idempotency, dedupe on the top-level event `id` (or the email id), not on `delivery.attempt`. If you already processed the event, return 2xx again instead of failing the duplicate. Retry-safe handlers should: * use `event.id` or `email.id` as a dedupe key; * perform external side effects idempotently; * return 2xx for duplicate events that were already processed; * avoid returning 4xx for unknown future event types you intentionally ignore. Primitive retries with backoff for delivery failures. A handler that keeps failing will surface in webhook delivery logs and can be replayed after you deploy a fix. Deliveries to [Function](/docs/functions) endpoints follow the same retry schedule. A function invocation can run for up to 10 minutes while awaiting I/O, so a delivery to a slow handler stays pending until the invocation finishes. An invocation that exceeds the 10-minute window is canceled, the delivery is marked failed with the `function_timeout` error code, and it is retried like any other failure. CPU time is a separate, shorter limit; see [Functions](/docs/functions#limits) for how the two relate. ## Replays Replay an inbound email when you need to re-deliver the stored event after fixing your endpoint. **CLI** ```bash primitive emails:replay-email-webhooks --id ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/emails//replay \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Webhook delivery attempts can also be replayed from the webhook delivery API. Replay does not create a new inbound email. It reuses the stored event and delivery data, so the same idempotency rules apply. Replay is rate-limited by a 10-second cooldown: replaying the same email within 10 seconds of its last delivery attempt returns `429 Too Many Requests`. Wait for the cooldown to pass before retrying. ## Endpoint Rules Endpoints can be scoped to domains and rules. Use this when one organization has multiple inbound workflows and only some addresses should reach a given endpoint. Rules are evaluated before delivery. If no active endpoint matches an inbound email, the message is still stored and can be read from the REST API. An endpoint's `rules` object accepts the following optional fields. All rules are ANDed together, and an empty `rules` (`{}`) means no filtering — the endpoint receives everything for its scope. |Field|Type|Effect| |--|--|--| |`max_size_bytes`|`integer`|Skip messages larger than this size.| |`exclude_attachments`|`boolean`|When `true`, only deliver messages that have no attachments.| |`attachment_limit_mb`|`number`|Skip messages whose total attachment size exceeds this many MB.| |`sender_whitelist`|`string[]`|When set, deliver only from these sender addresses.| |`sender_blacklist`|`string[]`|Skip messages from these sender addresses.| |`event_types`|`string[]`|Event types this endpoint receives, for example `["email.received", "email.bounced"]`. Omitted or unset means the endpoint receives all event types (the grandfathered default for endpoints that predate subscriptions).| `event_types` is the subscription model: a newer endpoint receives only the `email.*` events it lists, so a pre-existing endpoint will not get `email.bounced` (or other specialized types) until you add them to `event_types`. The other rules filter individual messages within the types the endpoint is subscribed to. Subscription values are matched by **exact string** and are not validated against the known event names, so a typo like `email.bouncd` saves without error and silently matches nothing. Copy event names verbatim from [Webhook Payload](/docs/webhook-payload). The endpoint test only sends `email.received`, so it cannot confirm a specialized subscription — verify `email.bounced` and the other report types with a real matching event or by checking webhook deliveries. See [Endpoints](/docs/endpoints) for the full endpoint configuration reference, including domain scoping and the subscription model. Domain-scoped endpoints override fallback endpoints. For an accepted inbound on a verified domain, Primitive first looks for enabled endpoints pinned to that domain. If any exist, only those endpoints are considered. Endpoints with `domain_id = null` receive mail only for domains without their own enabled scoped endpoint. Use endpoint rules when one organization has several mail workflows, for example: * `support@yourdomain.com` posts to a helpdesk webhook; * `bugs@yourdomain.com` invokes a triage Function; * `receipts@yourdomain.com` is stored for polling only. Rules should be narrow enough that a message reaches the intended handler, but not so narrow that important mail is silently skipped. Only one endpoint fires per scope: Primitive enforces one active endpoint per domain (and one org-wide fallback), and delivery resolves to a single endpoint for the winning scope, so give a domain to the one Function or webhook that should own it. Even skipped mail remains available through the REST API. ## Stored Email Access List and fetch inbound mail with the API: ```bash curl https://api.primitive.dev/v1/emails \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" curl https://api.primitive.dev/v1/emails/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` The full email resource includes parsed headers, body fields, attachments metadata, authentication results, delivery data, and download URLs where applicable. Use search for operational workflows that need to filter stored mail by sender, recipient, subject, body text, date, or processing state. ## Raw Content and Attachments Small raw emails may be included inline in the webhook payload. Larger raw content is stored and exposed through download URLs (`/v1/emails/{id}/raw` and `/v1/emails/{id}/attachments.tar.gz`). Both endpoints accept two forms of authentication: * **Signed URLs** delivered in the webhook payload carry a `token` query parameter and need no API key. They expire 24 hours after the webhook is delivered (the payload's `downloadExpiresAt` field states the exact time). * **API-key requests** (`Authorization: Bearer YOUR_API_KEY`) against the same URLs, without the `token` parameter, do not expire. Stored content remains downloadable this way indefinitely, until you discard it. An expired signed URL returns `401` with code `download_token_expired`; retry the same URL with API-key authentication instead. ## Discarding Content When you no longer need a stored message body, you can discard its content to free storage: ```bash curl -X POST https://api.primitive.dev/v1/emails//discard-content \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Content discard is irreversible: it permanently removes the stored raw email, parsed body, and attachments. Because of that, it is opt-in — you must enable it in your webhook settings first. Calling the endpoint before opting in returns `403` with the code `discard_not_enabled`. Threading metadata is preserved on the email row after a discard, so the message still participates in conversation threading. The content itself is gone, and a discarded email can no longer be replayed or replied to. ## Related Pages * [Webhook Payload](/docs/webhook-payload): event schema and field semantics. * [Endpoints](/docs/endpoints): endpoint scoping, rules, and event-type subscriptions. * [Signature Verification](/docs/signature-verification): HMAC verification details. * [Functions](/docs/functions): hosted inbound handlers. # Custom Routes URL: https://www.primitive.dev/docs/routing Bind a recipient address pattern to one destination. Exact, wildcard, and regex matching, evaluated in a deterministic order, with an explainable decision you can simulate before any mail arrives. A **route** binds a recipient address pattern to one destination endpoint. Inbound mail resolves to a **single** destination at delivery time — no fan-out — and the decision is fully explainable: you can ask where any address would land, and why, before a single email arrives. This is the product stance that an **email address is a dispatch pattern, not a mailbox**. A route maps *where* mail goes; your handler still owns *what* happens next. All endpoints below live under `https://api.primitive.dev/v1`. Every command shown also exists as a `primitive routes` CLI verb. ## Availability Custom routing is available to all organizations by default. It is governed by the `recipient_routing` organization entitlement; if the `/v1/routes` endpoints return `feature_disabled`, the entitlement has been disabled for your organization. Contact [support](https://www.primitive.dev/contact) or email the Primitive help agent at `dev@agent.primitive.dev` if you believe that is an error. ## Concepts * **Pattern** — the recipient address a route matches on, in one of three tiers: * `exact` — one full address, e.g. `billing@acme.dev`. * `wildcard` — a glob over the local part, e.g. `*@acme.dev` (the rest of a domain). * `regex`: a safe, linear-time pattern. Regex matching is available on the Power and Platinum plans; on other plans, use `exact` or `wildcard`. * **Destination** — exactly one endpoint. Provide an existing `endpoint_id`, or pass a `function_id`: Primitive binds the function's existing route-target endpoint, or mints one in the same transaction if the function has none (per-address function routing). * **Priority** — evaluation order within a scope. Lower is checked first. * **Scope** — a route is scoped to a domain, or org-wide when `domain_id` is null. Domain-scoped routes are evaluated before org-wide ones. * **Fallback** — if no route matches, mail falls through to the domain's default endpoint, then the org default. If neither exists, the outcome is `none`: the mail is stored, and nothing is delivered. ## How a route is chosen For a given normalized recipient, enabled matching routes are ranked in a deterministic order and the **first match wins**: 1. **Scope** — domain-scoped routes outrank org-wide routes. This is the primary key, so a domain-scoped route at `priority: 999` still beats an org-wide route at `priority: 1`. 2. **Priority ascending** — within the same scope, lower priority is checked first. 3. **Tier specificity** — `exact` > `wildcard` > `regex`. 4. **Wildcard literal length** — a wildcard with more fixed (non-`*`) characters is more specific and wins. 5. **Creation time**, then route id, as stable final tiebreaks. Mail is delivered to that one endpoint, and the full evaluation trace is recorded, so every delivery can answer "what went where, and why." ## Create a route Bind an exact address to a function. With `function_id`, the route-target endpoint is minted and bound for you: ```bash curl -X POST https://api.primitive.dev/v1/routes \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "match_type": "exact", "pattern": "billing@acme.dev", "function_id": "" }' ``` Catch the rest of the domain with a wildcard pointed at an existing endpoint, at a lower priority so the exact rule always wins: ```bash curl -X POST https://api.primitive.dev/v1/routes \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "match_type": "wildcard", "pattern": "*@acme.dev", "endpoint_id": "", "priority": 200 }' ``` Provide exactly one of `endpoint_id` or `function_id`. `priority` defaults to `100` and `enabled` defaults to `true`. ## Simulate before you ship Ask where an address would land — and why, rule by rule — without sending anything: ```bash curl -X POST https://api.primitive.dev/v1/routes/simulate \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "recipient": "sales@acme.dev" }' ``` ```json { "outcome": "matched", "recipient": "sales@acme.dev", "endpoint_id": "a0d6f29c-4e13-4b87-9c52-1f8a3e7b6d09", "matched_route_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31", "matched_tier": "wildcard", "matched_pattern": "*@acme.dev", "default_scope": null, "evaluated": [ { "route_id": "b7e2d4a1-9c3f-4e08-8a17-2d6c5f0b9e44", "tier": "exact", "pattern": "billing@acme.dev", "result": "miss", "reason": "recipient 'sales@acme.dev' is not 'billing@acme.dev'" }, { "route_id": "c4a9e1b7-3d28-4f60-b915-8e2c7a0d6f31", "tier": "wildcard", "pattern": "*@acme.dev", "result": "hit" } ], "truncated": false } ``` `outcome` is `matched` (a route hit), `defaulted` (no route matched, a fallback endpoint applied — see `default_scope`), or `none` (nothing matched and no fallback exists). `event_type` defaults to `email.received`; pass it to model how a different event subscribes. ## List, reorder, update, delete ```bash # routes ordered by priority, then creation time (see "How a route is chosen" for evaluation order) curl https://api.primitive.dev/v1/routes \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" # repriority in bulk curl -X POST https://api.primitive.dev/v1/routes/reorder \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "updates": [{ "id": "", "priority": 50 }] }' # change or disable a route curl -X PATCH https://api.primitive.dev/v1/routes/ \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "enabled": false }' # remove a route curl -X DELETE https://api.primitive.dev/v1/routes/ \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` ## From the CLI Every operation above is a `primitive routes` verb, generated from the same API spec: ```bash primitive routes create-route --match-type exact \ --pattern billing@acme.dev --function-id primitive routes list-routes primitive routes test sales@acme.dev ``` The CLI prints the response `data` as formatted JSON. Add `--envelope` to see the full `{ success, data, meta }` wrapper. ## Limits Routes are plan-limited by count. Regex matching requires the Power or Platinum plan. Disabled or deleted routes are skipped at delivery; a route pointing at a deactivated endpoint falls through to the next match rather than dropping mail. # Primitive API Sandbox URL: https://www.primitive.dev/docs/sandbox Exercise Primitive's send and receive API with synthetic data, no credentials, and no production side effects. Primitive provides a public test mode at `https://api.primitive.dev/v1`. No account or API key is required. Use the demo paths below to validate requests and inspect responses before calling authenticated production endpoints. ## Test endpoints |Method|URL|Behavior| |--|--|--| |POST|`https://api.primitive.dev/v1/send-mail/demo`|Validates a send request and returns a synthetic accepted response. No email is sent, queued, or stored.| |GET|`https://api.primitive.dev/v1/demo/emails`|Returns fixed synthetic email summaries.| |GET|`https://api.primitive.dev/v1/demo/emails/{id}`|Returns a synthetic email detail. Use an ID from the summaries.| |POST|`https://api.primitive.dev/v1/demo/jobs`|Returns a synthetic job and its status URL with HTTP 202.| |GET|`https://api.primitive.dev/v1/demo/jobs/{id}`|Returns a fixed processing status; this sample does not advance to completion.| Successful demo responses include `demo: true`. Demo reads expose no tenant data, and demo jobs run no real work. These endpoints are rate limited per IP; on HTTP 429, wait for the `Retry-After` interval before retrying. ## Try a send ```bash curl -i https://api.primitive.dev/v1/send-mail/demo \ -H 'Content-Type: application/json' \ -d '{"from":"agent@example.com","to":"recipient@example.com","subject":"Sandbox test","body_text":"Hello from the Primitive sandbox."}' ``` Expect HTTP 200 with `success: true`, `demo: true`, and a synthetic send record under `data`. The body uses the same send schema as the live endpoint. To inspect validation errors safely, repeat the request with `{}` and expect HTTP 400 with `success: false` and a typed `error.code` and `error.message`. ```bash curl https://api.primitive.dev/v1/demo/emails ``` The [OpenAPI document](https://api.primitive.dev/v1/openapi) describes these operations with `security: []`. See [error handling](/docs/errors) for the response envelope. ## Moving to production The sandbox covers synthetic send, receive, and job examples. It does not simulate domain provisioning, Functions, billing, or payments. For real mail, [obtain credentials](/docs/auth), switch to `/v1/send-mail` or `/v1/emails`, and supply `Authorization: Bearer prim_`. Authenticated endpoints operate on real organization data; a demo ID cannot identify a production resource. # SDKs URL: https://www.primitive.dev/docs/sdks Official Primitive SDKs for TypeScript (Node.js), Python, and Go, including install commands and high-level send/receive helpers. Primitive publishes official SDKs for Node.js, Python, and Go. Use SDKs in application code and Functions. Use the CLI for terminal workflows; install it with Homebrew from [`primitivedotdev/tap`](https://github.com/primitivedotdev/homebrew-tap) or with npm as `primitive` (also published under the scoped name `@primitivedotdev/cli`). |Language|Package|Runtime|Registry and API reference| |--|--|--|--| |TypeScript (Node.js)|`@primitivedotdev/sdk`|Node.js 22 or newer|[npm](https://www.npmjs.com/package/@primitivedotdev/sdk)| |Python|`primitivedotdev`|Python 3.10 or newer|[PyPI](https://pypi.org/project/primitivedotdev/)| |Go|`github.com/primitivedotdev/sdks/sdk-go`|Go 1.25 or newer|[pkg.go.dev](https://pkg.go.dev/github.com/primitivedotdev/sdks/sdk-go)| All three SDKs are open source in [github.com/primitivedotdev/sdks](https://github.com/primitivedotdev/sdks) and generated from the same OpenAPI spec. ## Install **Node.js** ```bash npm install @primitivedotdev/sdk pnpm add @primitivedotdev/sdk yarn add @primitivedotdev/sdk bun add @primitivedotdev/sdk ``` **Python** ```bash pip install primitivedotdev uv add primitivedotdev poetry add primitivedotdev ``` **Go** ```bash go get github.com/primitivedotdev/sdks/sdk-go@latest ``` ## Receive an Email **Node.js** ```typescript import primitive from '@primitivedotdev/sdk'; const client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY!, }); export async function POST(req: Request) { const email = await primitive.receive(req, { secret: process.env.PRIMITIVE_WEBHOOK_SECRET!, }); await client.reply(email, { text: 'Got it.', }); return Response.json({ ok: true }); } ``` **Python** ```python import os import primitive from fastapi import FastAPI, Request app = FastAPI() client = primitive.client(api_key=os.environ['PRIMITIVE_API_KEY']) @app.post('/webhooks/email') async def handle(request: Request): body = await request.body() email = primitive.receive( body=body, headers=dict(request.headers), secret=os.environ['PRIMITIVE_WEBHOOK_SECRET'], ) client.reply(email, {'text': 'Got it.'}) return {'ok': True} ``` **Go** ```go func handle(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) email, err := primitive.Receive(primitive.HandleWebhookOptions{ Body: body, Headers: r.Header, Secret: os.Getenv("PRIMITIVE_WEBHOOK_SECRET"), }) if err != nil { http.Error(w, "invalid signature", http.StatusForbidden) return } client, _ := primitive.NewClient(os.Getenv("PRIMITIVE_API_KEY")) client.Reply(context.Background(), email, primitive.ReplyParams{ BodyText: "Got it.", }) w.WriteHeader(http.StatusOK) } ``` Reply helpers use reply-specific body keys: `text` and `html` in Node.js and Python, and `BodyText` and `BodyHTML` in Go. Send helpers use send-specific names such as `bodyText` in TypeScript. ## Send an Email **Node.js** ```typescript const result = await client.send({ from: 'support@yourdomain.com', to: 'alice@example.com', subject: 'Order confirmed', bodyText: 'Your order is on its way.', wait: true, }); console.log(result.id, result.deliveryStatus); ``` **Python** ```python result = client.send( from_email='support@yourdomain.com', to='alice@example.com', subject='Order confirmed', body_text='Your order is on its way.', wait=True, ) print(result.id, result.delivery_status) ``` **Go** ```go wait := true result, err := client.Send(context.Background(), primitive.SendParams{ From: "support@yourdomain.com", To: "alice@example.com", Subject: "Order confirmed", BodyText: "Your order is on its way.", Wait: &wait, }) ``` ## High-Level Helpers The SDKs provide high-level helpers for: * verifying and parsing inbound webhooks; * sending new outbound messages; * replying to an inbound email with threading derived from the parent; * forwarding inbound context to a new recipient; * deciding whether to trust an inbound email (`validateEmailAuth` for an overall SPF/DKIM/DMARC verdict, `isTrustedSender` to anchor that verdict to an expected From domain); * verifying webhook signatures manually when you need lower-level control. ## Functions Runtime Caveat Hosted Primitive Functions execute JavaScript. Function handler examples are therefore TypeScript/JavaScript-only. Application code outside hosted Functions can use Node.js, Python, or Go SDKs. ## Low-Level API Client Inside hosted Functions, import the API-safe subpath: ```typescript import { createPrimitiveClient } from '@primitivedotdev/sdk/api'; ``` The `/api` subpath avoids `node:crypto` (which the root and `/webhook` entries pull in), and its file attachment and payload helpers load `node:fs` lazily at call time instead of importing it, so Workers-style runtimes can bundle it without Node compatibility. ## Primitive Memories Primitive Memories are durable JSON key-value records. In Node.js, use `createPrimitiveClient().memories`; org scope is the default, and function scope uses the function id UUID. ```ts import { createPrimitiveClient } from '@primitivedotdev/sdk/api'; const client = createPrimitiveClient({ apiKey: process.env.PRIMITIVE_API_KEY, }); await client.memories.set({ key: 'thread:latest', value: { email_id: 'em_123' }, }); const memory = await client.memories.get('thread:latest'); const page = await client.memories.search({ prefix: 'thread:', includeValue: false, }); await client.memories.delete('thread:latest'); ``` Pass `scope: { type: 'function', id: '' }` when setting a function-scoped memory. For get, delete, and search, pass `scope_type=function&scope_id=`. Values must serialize as JSON. `client.memories.search` is key prefix search, not free-text or semantic search; use `client.semanticSearch(...)` for mail search. See [Primitive Memories](/docs/memories) for TTLs, version checks, metadata, search, and billing behavior. ## x402 Payments All three SDKs ship an x402 client for collecting and paying USDC non-custodially. Import the x402 client (in Node, `@primitivedotdev/sdk/x402`; in Python, `primitive.x402`; in Go, the `primitive` package's `NewX402Client`), register a payout address, create a challenge, and settle it with a locally-held key. See [Collecting Payments](/docs/collecting-payments) for the full flow. See [CLI](/docs/cli) for terminal workflows. # Search, Threads & Conversations URL: https://www.primitive.dev/docs/search Filter stored inbound mail with the search DSL, read the full back-and-forth as conversations or threads, and wait for a reply to arrive. Once Primitive stores an inbound email, you can list it, search it with structured filters and full-text matching, and read it in the context of the wider conversation it belongs to. This page covers the read surface: listing the inbox, the search filter set, facets and snippets, conversations versus threads, and the `awaitReply` long-poll that blocks until someone replies. For the write side — sending, replying, and forwarding — see [Sending Mail](/docs/sending). For inbound delivery, webhooks, and replays, see [Receiving Mail](/docs/receiving). ## Listing the Inbox `GET /v1/emails` returns stored inbound mail newest-first with cursor pagination. ```bash curl "https://api.primitive.dev/v1/emails?limit=50" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Supported query parameters: |Param|Description| |--|--| |`limit`|Page size, `1`–`100` (default `50`).| |`cursor`|Continuation cursor from a previous response's `meta.cursor`.| |`domain_id`|Restrict to one verified domain.| |`status`|One of `pending`, `accepted`, `completed`, `rejected`.| |`search`|Free-text substring match across sender, recipient, and subject.| |`date_from`, `date_to`|Inclusive ISO 8601 bounds on `created_at`, with `Z` or a UTC offset (`2026-10-02T00:00:00Z`, `2026-10-02T00:00:00-04:00`). A time with no zone is rejected.| |`since`|Forward-tail cursor: return only emails strictly newer than this cursor, oldest-first. Mutually exclusive with `cursor`.| |`wait`|Long-poll hold in seconds, `0`–`30` (default `0`). Holds the request open until a newer email arrives or the timeout elapses. Requires `since`.| The response carries `data` (the page of emails) and `meta` (`total`, `limit`, and the next `cursor`, which is `null` when you reach the end). `meta.total` counts the whole matching set, up to 10,000. When the request has `search` or `since`, it counts only what this page saw instead: it is exact whenever no further match exists, and `meta.total_capped` is `true` only when the API saw a match beyond this page, meaning at least that many match and more follow via `meta.cursor`. A page that holds exactly the last `limit` matches is full but reports `total_capped: false`: ```json { "success": true, "data": [ { "id": "…", "sender": "alice@example.com", "subject": "Invoice", "status": "completed" } ], "meta": { "total": 128, "limit": 50, "cursor": "2026-06-25T18:04:11.123456Z|" } } ``` The list endpoint's `search` is a simple substring match. For relevance ranking, body text, attachment filters, or date operators, use search. ### Tailing the inbox To watch for new mail without missing or double-reading messages, use `since` + `wait` rather than re-scanning by date. Pass the last `cursor` you saw as `since` and a `wait` of up to 30 seconds; the request returns as soon as a newer email arrives (oldest-first), or empty when the hold times out. Carry the new `cursor` into the next call: ```bash curl "https://api.primitive.dev/v1/emails?since=2026-06-25T18:04:11.123456Z%7C&wait=25" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` `since` keys on visibility order, so this loop is lossless — prefer it over polling `search?date_from=...` for live tailing. ## Searching `GET /v1/emails/search` is the structured query endpoint. It accepts dedicated filter parameters, a full-text query (`q`), or both. Each parameter accepts at most one value — repeating a parameter is a validation error. ```bash curl "https://api.primitive.dev/v1/emails/search?q=invoice&from=billing@acme.com&has_attachment=true&sort=received_at_desc" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ### Filter Parameters |Param|Type|Description| |--|--|--| |`q`|string (≤500)|Full-text query matched across subject, body, sender, and recipient. Supports the [search DSL](#search-dsl).| |`from`|string (≤255)|Sender address (when it contains `@`) or sender domain.| |`to`|string (≤255)|Recipient address or domain.| |`subject`|string (≤500)|Full-text match against the subject.| |`body`|string (≤2000)|Full-text match against the body.| |`domain_id`|uuid|Restrict to one verified inbound domain.| |`reply_to_sent_email_id`|uuid|Only inbound emails that are replies to a specific sent email.| |`status`|enum|`pending`, `accepted`, `completed`, or `rejected`.| |`date_from`, `date_to`|ISO 8601|Inclusive bounds on `received_at`, with `Z` or a UTC offset (`2026-10-02T00:00:00-04:00`). A time with no zone is rejected.| |`has_attachment`|`true` / `false`|Whether the email has attachments.| |`spam_score_lt`|number|Spam score strictly below this value.| |`spam_score_gte`|number|Spam score at or above this value.| |`awaiting`|`you` / `them`|Whose turn it is in the conversation. Matches delivered mail only: `rejected` emails are never returned by it.| |`automated`|`true` / `false`|Whether the email is classified as automated (bounces, auto-replies, mailing lists, reports), decided at ingest from its envelope and captured headers. A best-effort signal, not proof of who sent it.| |`sort`|enum|`relevance`, `received_at_desc`, or `received_at_asc`.| |`cursor`|string (≤200)|Continuation cursor from `meta.cursor`.| |`limit`|number|Page size, `1`–`100` (default `50`).| |`snippet`|`true` / `false`|Include the `highlights` object on each result (default `true`). The `snippet` body preview on each result is returned either way.| |`include_facets`|`true` / `false`|Include aggregated facet counts (default `true`).| `sort` defaults to `relevance` when the query carries text-bearing terms (`q`, `subject`, or `body`), and to `received_at_desc` otherwise. Requesting `sort=relevance` without any text term is a validation error. The cursor's sort mode must match the `sort` you request on the follow-up page. To scan a fixed window of matching mail oldest-first, sort `received_at_asc` and bound it with `date_from`: ```bash curl "https://api.primitive.dev/v1/emails/search?sort=received_at_asc&date_from=2026-06-26T00:00:00Z" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` For *live* tailing of newly-arrived mail, prefer the list endpoint's lossless `since` + `wait` long-poll (see [Tailing the inbox](#tailing-the-inbox)) rather than repeatedly advancing `date_from`. ### Search DSL The `q` parameter accepts plain terms, `"quoted phrases"`, and `field:value` filters. A query can hold up to 32 terms. Fields combine with the structured parameters above, so use whichever is more convenient. A `q` made only of field filters, such as `q=automated:false awaiting:you`, searches no text: it behaves exactly like the equivalent structured parameters, sorts by `received_at_desc` by default, and returns no highlights. Text you do type must leave at least one searchable word after stop-word removal, so `q=the` is rejected with a `validation_error`, with or without field filters beside it. |Field|Example|Meaning| |--|--|--| |`from:`|`from:alice@example.com` / `from:example.com`|Sender address (with `@`) or sender domain.| |`to:`|`to:support@yourdomain.com`|Recipient address or domain.| |`subject:`|`subject:"order confirmed"`|Full-text subject match.| |`body:`|`body:refund`|Full-text body match.| |`domain:`|`domain:yourdomain.com`|Receiving domain.| |`status:`|`status:completed`|`pending`, `accepted`, `completed`, or `rejected`.| |`has:`|`has:attachment`|Only `attachment` is accepted.| |`before:`|`before:2026-06-01`|`received_at` at or before the date (or ISO 8601 timestamp).| |`after:`|`after:2026-05-01`|`received_at` at or after the date.| |`awaiting:`|`awaiting:you`|`you` or `them`: whose turn it is in the conversation (see the `awaiting` field). Matches delivered mail only: an email whose `status` is `rejected` never matches either value.| |`automated:`|`automated:false`|`true` or `false`: whether the email is classified as automated from its envelope and captured headers (see the `automated` field and its `automated_reasons`).| A combined example — invoices from a domain, with an attachment, in a date window, that mention "overdue": ```bash curl -G "https://api.primitive.dev/v1/emails/search" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ --data-urlencode 'q=overdue from:acme.com has:attachment after:2026-05-01 before:2026-06-01' \ --data-urlencode 'sort=relevance' ``` If a text query reduces to nothing but stop-words, the API returns a validation error asking you to add a non-stop-word term or fall back to structured filters. #### Alternatives with `OR` Every term in `q` must match. To accept any one of several, put an upper-case `OR` between them: ```bash curl -G "https://api.primitive.dev/v1/emails/search" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ --data-urlencode 'q=invoice OR receipt OR "past due" from:acme.com' ``` * `OR` joins text terms only: words, `"quoted phrases"`, and `subject:` / `body:` terms. It cannot join filters such as `from:` or `has:`; run one search per value instead. * `OR` binds tighter than the implicit AND, so `acme invoice OR receipt` means `acme` and either `invoice` or `receipt`. There are no parentheses. * It must be upper case. Lower-case `or` is an ordinary word, and a stop word. To search for the word itself, quote it: `"OR"`. * Each alternative counts toward the 32-term limit, and must contain a searchable word: `the OR invoice` is rejected. There is no `NOT`: a search cannot exclude a term, and an upper-case `NOT` in `q` is rejected rather than ignored. With `prefix=true`, a trailing `OR` or `NOT` is read as a word still being typed. A `q` the search cannot honour is always a `validation_error` whose message says what is wrong and what is accepted: an operator used where it is not supported, an unknown `field:` (quote text that contains a colon, such as a URL), an unterminated quote, or text that is all stop-words. ### Snippets When `snippet=true` (the default) and the query has text-bearing terms, each result carries a `highlights` object with matched fragments for `subject` and `body`: ```json { "id": "7d8f2c1e-3b4a-4f6d-9e21-5c0a7b8d9e10", "message_id": "", "domain_id": "0c9e8d7f-6a5b-4c3d-8e2f-1a0b9c8d7e6f", "org_id": "4b3a2c1d-0e9f-4a8b-9c7d-6e5f4a3b2c1d", "status": "completed", "sender": "billing@example.com", "recipient": "agent@acme.primitive.email", "subject": "Invoice 4021 overdue", "domain": "acme.primitive.email", "spam_score": 0.4, "created_at": "2026-09-20T14:03:11.482Z", "received_at": "2026-09-20T14:03:11.391Z", "raw_size_bytes": 18233, "attachment_count": 1, "webhook_status": "fired", "webhook_attempt_count": 1, "from_known_address": true, "reply_count": 0, "last_replied_at": null, "awaiting": "you", "automated": false, "automated_reasons": [], "score": 0.61, "highlights": { "subject": ["Invoice 4021 overdue"], "body": ["…your balance is now overdue…"] } } ``` `score` is present only when results are sorted by relevance. Snippets are omitted for pure structured queries (no `q`, `subject`, or `body`), since there is nothing to highlight. ### Facets When `include_facets=true` (the default), the response adds a `facets` block aggregating the full matching set — the top senders and domains (up to 20 buckets each), plus status and attachment counts: ```json { "facets": { "by_sender": [{ "value": "alice@example.com", "count": 12 }], "by_domain": [{ "value": "example.com", "count": 18 }], "by_status": [{ "value": "completed", "count": 30 }], "has_attachment": { "true": 7, "false": 23 } } } ``` Each of `by_sender`, `by_domain` and `by_status` is ordered by `count`, highest first. Values with the same count are ordered by `value` ascending, compared as the bytes of its UTF-8 encoding (so upper case sorts before lower case), and a `null` value comes last. `by_sender` and `by_domain` keep the first 20 values in that order, so when more values tie at the cutoff than there are slots, the same request over the same mail always lists the same ones. ### Result Cap and Limits Search counts and pagination are capped at **10,000** matching emails. When the matching set is larger, `meta.total` reports `10000` and `meta.total_capped` is `true` — narrow the query (tighter date range or more filters) to reach the rows beyond the cap. The page itself still paginates normally via `meta.cursor`. ```json { "data": [ /* … */ ], "meta": { "total": 10000, "total_capped": true, "limit": 50, "cursor": "…", "sort": "received_at_desc" } } ``` A query that exceeds the server's time budget returns `504 search_timeout`. Narrow the date range or add filters and retry. For a text search (`q`, `subject` or `body`), the page is always returned once it has been read. If the count or the facets then run out of time, the response is still `200` with the page (which may itself be partial, see below): `meta.total` is `null` with `meta.total_unavailable: true`, and the `facets` block is omitted with `meta.facets_unavailable: true`. When a text search matches more than 10,000 emails, its facets cover the most recent 10,000 matches and `meta.facets_capped` is `true`. Each of these flags appears only when it is `true`. ### Partial pages A text search whose filters reject most of what the text matches, such as a one-letter `prefix=true` search with a `from` that matches little or nothing, can stop a page at its time bound before the page fills. The response is then `200` with `meta.partial: true`: `data` holds fewer than `limit` results, possibly none, and `meta.cursor` continues the search after the last email examined. Every result returned is correct and in order, and following `meta.cursor` until it is `null` reads exactly the results a single unbounded search would have returned. So a page shorter than `limit` does not mean the results are exhausted. Only a `null` `meta.cursor` does. ```json { "data": [], "meta": { "total": null, "total_unavailable": true, "total_capped": false, "limit": 50, "cursor": "…", "sort": "received_at_desc", "partial": true } } ``` ## Conversations A conversation is the full back-and-forth — every inbound and outbound message — that an email belongs to, returned as ordered, chat-model-ready turns with bodies inline. `GET /v1/emails/{id}/conversation` takes the id of any **inbound** email in the conversation: ```bash curl "https://api.primitive.dev/v1/emails//conversation" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Messages come back oldest-first. Each turn has a `direction` (`inbound` or `outbound`) and a derived `role` (inbound → `user`, outbound → `assistant`), so the array drops straight into a chat model: ```json { "success": true, "data": { "thread_id": "…", "subject": "Order confirmed", "message_count": 3, "truncated": false, "messages": [ { "role": "user", "direction": "inbound", "id": "…", "from": "alice@example.com", "subject": "Order confirmed", "text": "Where is my order?" }, { "role": "assistant", "direction": "outbound", "id": "…", "from": "support@yourdomain.com", "subject": "Re: Order confirmed", "text": "It ships today." } ] } } ``` For a brand-new message with no thread yet, the response contains just that single turn. Conversations are capped at **200** messages; when the cap is reached, `truncated` is `true` and `message_count` reflects the full recorded count. ### Jumping to a Message The cap keeps only the newest 200 messages. To open an older message inside its conversation, for example a search result, pass `around` with its id. The response holds up to `limit` messages before it (default `25`, at most `100`), the message itself, and up to `limit` after it, oldest-first, with the same fields and the same access rules as the full read: ```bash curl "https://api.primitive.dev/v1/emails//conversation?around=&limit=25" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` `around` takes an inbound email id or a sent email id from the same conversation, and on these reads the path id may be a sent email id too, so an outbound search result opens directly. A message that is not in this conversation, or that your key cannot read, returns `404 not_found`. The response adds `before_cursor`, `after_cursor`, `has_more_before` and `has_more_after`. To scroll, pass `before=` for older messages or `after=` for newer ones; each page returns up to `limit` messages and fresh cursors. Pass cursors back unchanged; their format may change. On these reads `truncated` is `true` when there are more messages on either side. Send only one of `around`, `before`, `after` and `since` per request; a malformed value or a combination returns `400 validation_error`. ## Threads A thread is the lightweight index of the same exchange. `GET /v1/threads/{id}` returns thread metadata plus all inbound and outbound messages interleaved oldest-first — but without bodies. Each entry carries a `direction` and an `id`; fetch the body on demand with `GET /v1/emails/{id}` for inbound or `GET /v1/sent-emails/{id}` for outbound. ```bash curl "https://api.primitive.dev/v1/threads/" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Discover a `thread_id` from the `thread_id` field on an email or sent-email **detail** record (`GET /v1/emails/{id}`, `GET /v1/sent-emails/{id}`), a sent-email list row (`GET /v1/sent-emails`), an inbound list row (`GET /v1/emails`), the conversation response, or a webhook payload. Search rows (`GET /v1/emails/search`) do not include it. Threads are also capped at **200** messages; compare `message_count` against `messages.length` to detect truncation. **Conversation or thread?** Use the conversation endpoint when you want the whole exchange with bodies in one call — for example to hand it to a model. Use the thread endpoint when you only need the message index and will fetch individual bodies selectively, which keeps the response small for long exchanges. ## Waiting for a Reply `GET /v1/sent-emails/{id}/reply` (the `awaitReply` operation) answers the question "did they reply yet?" for a message you sent. Pass the id of the sent email, and Primitive returns a threaded reply if one has arrived. By default it returns immediately — with the reply, or `reply: null` if nothing has arrived: ```bash curl "https://api.primitive.dev/v1/sent-emails//reply" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` The response also carries `sent_email_id`, `waited`, and `timed_out`. Use `timed_out` to tell "no reply yet" apart from "waited and gave up." With `wait=true` it long-polls, holding the request open until a verified reply lands or `wait_timeout_ms` elapses (1000 to 30000 ms, default 10000). This makes synchronous agent-to-agent chat a single call: ```bash curl "https://api.primitive.dev/v1/sent-emails//reply?wait=true&wait_timeout_ms=30000" \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` ### Verifying who replied Threading (`In-Reply-To`) routes a message to your send, but it is not proof of authorship: every recipient of your message sees the same Message-ID, and anyone who learns it can reuse it. Each reply therefore carries: |Field|Meaning| |--|--| |`sender_verified`|`true` when the reply's From address is one of the send's `to`, `cc` or `bcc` recipients and it either passed DMARC with aligned SPF or DKIM within that address's domain, or was delivered directly by Primitive.| |`sender_matches_recipient`|`true` when the From address is one of the send's recipients, whether or not it was authenticated. On its own this is not proof of authorship.| |`auth`|The SPF, DKIM and DMARC verdicts for the reply, in the same shape as `auth` on an email.| Check `sender_verified` before acting on instructions in a reply. When it is `false`, treat the reply as untrusted input. When several messages thread onto the same send, they are ranked rather than taken in arrival order. A verified reply is preferred over an unverified one, and an ordinary reply over an automated one (`Auto-Submitted`, such as an out-of-office). Bounces, complaints and reports are never returned. With `wait=true`, the call keeps waiting while the best reply so far is unverified, and returns it with `sender_verified: false` only if nothing verified arrives before `wait_timeout_ms`. So when replies may come from senders that cannot be verified, budget for the full timeout. Because replies are ranked, a later call can return a different reply than an earlier one. ### `awaitReply` vs. Send Wait Mode These two waits look similar but block on different events — don't confuse them: ||Send [Wait Mode](/docs/sending#wait-mode) (`wait: true` on `/v1/send-mail`)|`awaitReply` (`wait=true` on `/v1/sent-emails/{id}/reply`)| |--|--|--| |Blocks until|the **outbound delivery** outcome is known (the receiving side's SMTP response, or timeout)|a **reply** to your message arrives on the thread| |Answers|"was my message delivered?"|"did they write back?"| |Returns|delivery status (`delivered`, `bounced`, `deferred`, `wait_timeout`)|the threaded reply email, or `reply: null` on timeout| Send Wait Mode tells you the message reached the recipient's mail server. `awaitReply` tells you the recipient (or their agent) actually responded. A typical agent-to-agent exchange uses both: send with Wait Mode to confirm delivery, then `awaitReply` with `wait=true` to collect the response. ## Related Pages * [Sending Mail](/docs/sending): outbound send, replies, and Wait Mode. * [Receiving Mail](/docs/receiving): inbound delivery, webhooks, and stored email access. * [Webhook Payload](/docs/webhook-payload): inbound event schema. * [REST API](/docs/api): full endpoint list and envelope shape. * [SDKs](/docs/sdks): high-level helpers for each language. * [CLI](/docs/cli): terminal workflows. # Sending Mail URL: https://www.primitive.dev/docs/sending Outbound send, multiple recipients, cc/bcc, reply-to, tags, recipient gates, idempotency, delivery waits, attachments, inline images, scheduled sends, and rate limits. Primitive sends outbound mail from verified identities. Use the CLI for operational workflows, SDKs for application code, and REST for runtimes where an SDK is not available. ## Endpoint All sends — with or without attachments — go to a single endpoint: ```text POST https://api.primitive.dev/v1/send-mail ``` Attachments up to the inline cap are included inline (base64-encoded) in the same JSON body; larger attachments are uploaded first to `/v1/payloads` and delivered by reference via `payload_attachments`. See [Attachments](#attachments) for the size limits. ## Send with CLI or cURL **CLI** ```bash primitive send \ --to alice@example.com \ --subject "Hello" \ --body "Hi from Primitive" \ --wait ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Order confirmed", "body_text": "Your order is on its way.", "wait": true }' ``` The CLI can auto-resolve a verified `from` address for the organization. Use `primitive send --help` for all flags. REST fields use `snake_case`. SDKs expose idiomatic names for each language, such as `bodyText` in TypeScript and `BodyText` in Go. ### Recipient and Body Limits `to` accepts a single address string or an array of addresses. Each entry must be exactly one address; a comma-separated list inside one entry is rejected with a `validation_error`, so use the array form to address multiple recipients. The combined recipient count across `to`, `cc`, and `bcc` is capped at **100** per send. At least one of `body_text` or `body_html` is required. `body_text` and `body_html` together are capped at **256 KB** (combined UTF-8 byte length). A request over the cap is rejected with a `validation_error` before the message is accepted. For larger payloads, send a link or an attachment instead. ## SDK Send **Node.js** ```typescript import primitive from '@primitivedotdev/sdk'; const client = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY!, }); const result = await client.send({ from: 'support@yourdomain.com', to: 'alice@example.com', subject: 'Order confirmed', bodyText: 'Your order is on its way.', wait: true, }); console.log(result.id, result.deliveryStatus, result.smtpResponseCode); ``` **Python** ```python import os import primitive client = primitive.client(api_key=os.environ['PRIMITIVE_API_KEY']) result = client.send( from_email='support@yourdomain.com', to='alice@example.com', subject='Order confirmed', body_text='Your order is on its way.', wait=True, ) print(result.id, result.delivery_status, result.smtp_response_code) ``` **Go** ```go wait := true result, err := client.Send(context.Background(), primitive.SendParams{ From: "support@yourdomain.com", To: "alice@example.com", Subject: "Order confirmed", BodyText: "Your order is on its way.", Wait: &wait, }) if err != nil { log.Fatal(err) } fmt.Println(result.ID) ``` ## Multiple Recipients, Cc, and Bcc A send with several recipients goes out as one message, not a fan-out into separate sends: every address in `to`, `cc`, and `bcc` receives the same SMTP message, and a single send record tracks the delivery. ```json { "from": "support@yourdomain.com", "to": ["alice@example.com", "bob@example.com"], "cc": "manager@example.com", "bcc": ["audit@yourdomain.com"], "subject": "Order confirmed", "body_text": "Your order is on its way." } ``` * `cc` and `bcc` take the same shape as `to`: a single address string or an array, with exactly one address per entry. * The combined `to` + `cc` + `bcc` count is capped at **100** recipients per send. * Every recipient, including `cc` and `bcc`, must pass the same [recipient gates](#who-you-can-send-to); one denied address rejects the whole send with `403 recipient_not_allowed` naming that address. * `bcc` recipients receive the message, but the `Bcc` header is never included in the transmitted message, so other recipients cannot see them. The bcc list is stored on your own send record and read back on `GET /v1/sent-emails/{id}`. * The first `to` entry is the primary recipient: it fills the scalar `to_address` field in list responses, while the detail read-back also carries the full `to_addresses`, `cc`, and `bcc` arrays. ## Who You Can Send From You can send from any verified domain on your organization with an active DKIM key. Managed `*.primitive.email` subdomains are verified by construction. Custom domains need outbound DNS records published and verified first. ## Who You Can Send To Primitive deliberately gates new outbound traffic. This protects deliverability and prevents a new account from becoming an unauthenticated bulk sender. Allowed recipient categories include: * any address on a Primitive-managed zone such as `*.primitive.email`; * addresses on a custom domain verified by your organization; * email addresses of members of your Primitive organization; * addresses that previously sent you authenticated inbound mail, when replying to known addresses is allowed; * any domain after `send_to_any_domain` is enabled for your organization. To inspect the active rules, call: **CLI** ```bash primitive sending:get-send-permissions ``` **cURL** ```bash curl https://api.primitive.dev/v1/send-permissions \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` If a send is denied, Primitive returns `403 recipient_not_allowed` with a structured `gates` array and per-gate `fix.action` hints. Branch on the error code, not on the human-readable message. ```json { "success": false, "error": { "code": "recipient_not_allowed", "message": "cannot send to alice@external.com", "gates": [ { "name": "send_to_confirmed_domains", "reason": "domain_not_confirmed", "message": "external.com is not on this account's confirmed-domain list.", "subject": "external.com" }, { "name": "send_to_known_addresses", "reason": "recipient_not_known", "message": "You have not exchanged authenticated mail with alice@external.com yet.", "subject": "alice@external.com", "fix": { "action": "wait_for_inbound", "subject": "alice@external.com" } } ], "request_id": "req_..." } } ``` Each gate carries `name`, `reason`, `message`, and `subject`; only some gates carry a `fix` (an object of `action` + `subject`) — for example, `send_to_known_addresses` suggests `wait_for_inbound`, while `send_to_confirmed_domains` has no automatic fix. If **no** recipient gate is granted to your org at all, the response instead omits `gates` and carries `details.required_entitlements` listing the gates that would unlock sending. Sending to your own verified domains never needs an entitlement. Any address on a domain your organization has added and completed ownership verification for is a permitted recipient, on the same ownership evidence that lets you send *from* that domain. It appears as the `send_to_own_verified_domains` gate on `/v1/sendability` and as a `your_domain` rule on `/v1/send-permissions`. One further allowance needs no entitlement and is not something you can invoke: when you run a Function test invocation, Primitive itself sends the test message to the generated address on your inbound domain. That single delivery is authorized by the pending test run recorded for it, matched on the exact recipient AND sender addresses and on your organization's current, verified ownership of the recipient domain, and it stops authorizing anything the moment the message is sent. If ownership verification on that domain has lapsed, the test invocation is refused before it is sent, the same way inbound mail to that domain is refused. It appears as the `send_to_function_test_recipient` gate on `/v1/sendability`. It is deliberately absent from `/v1/send-permissions`, which lists standing rules, because it is neither standing nor yours. The remaining gates are entitlement-backed: |Gate|Allows| |--|--| |`send_to_primitive_managed_domains`|Active domains hosted on Primitive, including `*.primitive.email`.| |`send_to_confirmed_domains`|Addresses at any active domain on your organization, including ones whose ownership is not verified. Verified domains do not need it.| |`send_to_org_member_emails`|Email addresses owned by organization members.| |`send_to_known_addresses`|External addresses that previously sent you authenticated mail.| |`send_to_opted_in_domains`|Domains that published an [`_agents` opt-in record](/docs/agent-authorization) authorizing agents on Primitive.| |`send_to_any_domain`|Any recipient domain after broader sending is enabled.| ## Replying Replying derives the recipient and threading headers from an inbound email. **Node.js** ```typescript await client.reply(email, { text: 'Got it.', }); ``` **Python** ```python client.reply(email, {'text': 'Got it.'}) ``` **Go** ```go _, err := client.Reply(context.Background(), email, primitive.ReplyParams{ BodyText: "Got it.", }) if err != nil { log.Fatal(err) } ``` Use the CLI or raw API from terminal workflows: **CLI** ```bash primitive sending:reply-to-email \ --id \ --body-text "Got it." ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/emails//reply \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"body_text":"Got it."}' ``` Primitive sets the `Re:` subject, `In-Reply-To`, and `References` headers from the parent message when available. If the parent is not repliable, the API returns `422 inbound_not_repliable` with a stable reason: `inbound_rejected`, `content_discarded`, or `missing_recipient`. A parent without a `message_id` is still repliable; the threading headers are simply omitted. ## Forwarding Forwarding sends a new outbound message that carries the context of an inbound email. **Node.js** ```typescript await client.forward(email, { to: 'oncall@example.com', bodyText: 'FYI, new bug report.', }); ``` **Python** ```python client.forward(email, to='oncall@example.com', body_text='FYI, new bug report.') ``` **Go** ```go _, err := client.Forward(context.Background(), email, primitive.ForwardParams{ To: "oncall@example.com", BodyText: "FYI, new bug report.", }) if err != nil { log.Fatal(err) } ``` Use forwarding for escalation, triage, and workflows where the final recipient is not the original sender. ## Manual Threading The reply and forward helpers set threading headers for you. When you build a send by hand and want it to thread into an existing conversation, pass the threading fields directly: * `in_reply_to`: the `Message-ID` of the message you are replying to; * `references`: the ordered chain of prior `Message-ID`s in the thread. ```json { "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Re: Order confirmed", "body_text": "Following up on your order.", "in_reply_to": "", "references": ["", ""] } ``` `in_reply_to` is a single Message-ID (at most 998 characters). `references` is an array of up to **100** Message-IDs whose combined length is at most about **8,000 characters**. Values may not contain control characters. When you reply through `/v1/emails/{id}/reply`, Primitive derives these headers from the parent automatically, so manual threading is for sends you assemble yourself. ## Reply-To `reply_to` sets the `Reply-To` header, directing replies somewhere other than the `from` address. It accepts a single address string or an array of up to **10** addresses. Each entry must be exactly one RFC 5322 mailbox; a display name is allowed. ```json { "from": "noreply@yourdomain.com", "to": "alice@example.com", "reply_to": ["Support "], "subject": "Receipt", "body_text": "Thanks for your order." } ``` Reply-To is a header, not a recipient: it adds no delivery, is not checked against recipient gates, and does not count toward the 100-recipient cap. The normalized addresses are read back as `reply_to` on `GET /v1/sent-emails/{id}`. ## Custom Headers Most message headers are derived by Primitive and cannot be overridden. A narrow allowlist of headers can be set per send through the `headers` map: * `Auto-Submitted` — mark auto-generated or auto-reply mail (RFC 3834); * `X-Auto-Response-Suppress` — suppress the recipient's own auto-replies to your message; * `Precedence` — legacy bulk/list/junk hint some receivers use for loop prevention. ```json { "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Receipt", "body_text": "Thanks for your order.", "headers": { "Auto-Submitted": "auto-generated", "X-Auto-Response-Suppress": "All" } } ``` Header lookup is case-insensitive. You may set at most **25** entries; each key is at most 64 characters and each value is printable ASCII (no control characters) and at most 200 characters. Headers not on the allowlist — for example `Return-Path`, `Sender`, or `List-Unsubscribe` — are rejected with a `validation_error` naming the offending header. Threading headers are set with `in_reply_to` and `references` (above), not through this map. ## Idempotency Pass an idempotency key when retrying a send from your own infrastructure. ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Idempotency-Key: order-123-email-confirmation" \ -H "Content-Type: application/json" \ -d '{"from":"support@yourdomain.com","to":"alice@example.com","subject":"Confirmed","body_text":"Done"}' ``` The same key and canonical payload return the cached response instead of creating a second send. Reusing the same key with a different payload also replays the original outcome (no new send fires); the replayed response carries `idempotent_replay: true` and echoes the original `content_hash`, so compare it against your payload to detect the mismatch. An explicit key has no time-based expiry, unlike the automatic key described next. [Canceling a scheduled send](#scheduled-sends) releases its key, so posting the same key again creates a fresh send instead of replaying the canceled one. ### Sends without a key A send with no `Idempotency-Key` header still gets a key. Primitive derives it from the canonical request content (`from`, every recipient, `subject`, bodies, attachments, custom headers, tags, threading fields and `scheduled_at`) and a fixed 5-minute window. The effective key is echoed in the `Idempotency-Key` response header. The consequence: **two keyless sends with identical content to the same recipients inside the same 5-minute window are one send.** The first is sent. The second sends nothing and returns the first send's result: normally `200` with the first send's `id`, `idempotent_replay: true`, and an `idempotency` object saying why, or the first send's stored error if it had failed (see [Detecting a replay](#detecting-a-replay)). This absorbs retry storms after a timeout, and it also means a deliberate repeat (the same reminder, ping or test message twice in a row) is not sent the second time. One kind of send is outside this rule: an acknowledgement sent with `fyi: true` and no key is a distinct send on every request, so two identical keyless acknowledgements both go out. Pass an `Idempotency-Key` when retrying one. Windows are fixed, not sliding. They are aligned to the clock (`:00`, `:05`, `:10`, and so on, UTC), so two identical sends less than five minutes apart that fall on either side of a boundary are both sent. Do not rely on the window to deduplicate: pass a key for that. To send identical content again, give each logical send its own key: ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Idempotency-Key: reminder-7f3a-attempt-2" \ -H "Content-Type: application/json" \ -d '{"from":"support@yourdomain.com","to":"alice@example.com","subject":"Reminder","body_text":"Standup in 10 minutes."}' ``` Use one key per message you intend to deliver, and reuse that key only when retrying that same message. ### Detecting a replay Any response that answers with an existing send instead of making a new one says so in three places: * the `Idempotent-Replayed: true` response header, absent on a request that sent; * `idempotent_replay: true` in the body; * an `idempotency` object in the body, absent on a request that sent. The body also carries `dedup_reason`, which says why the request matched an earlier send. It is absent or `null` on a request that sent. ```json { "success": true, "data": { "id": "3f1c0a9e-2b7d-4e5a-9c6f-8d2e1a4b5c6d", "status": "submitted_to_agent", "idempotent_replay": true, "dedup_reason": "content_hash_match", "idempotency": { "replayed": true, "key_source": "auto_content", "original_sent_email_id": "3f1c0a9e-2b7d-4e5a-9c6f-8d2e1a4b5c6d", "original_created_at": "2026-10-06T12:00:07.412Z", "window_seconds": 300 } } } ``` |Field|Meaning| |--|--| |`dedup_reason`|`content_hash_match`: the request's idempotency key matched an earlier send, whether that key was your `Idempotency-Key` or one derived from the content. `parent_already_replied`: a keyless reply to an inbound email you had already replied to (see below).| |`key_source`|`explicit`: you sent an `Idempotency-Key`. `auto_content`: you sent none, and the key came from the content and the 5-minute window. `function_trigger`: a [Function](/docs/functions#idempotency-and-retries) sent with no key, and the key came from the content, the Function and the inbound email that invoked it.| |`original_sent_email_id`|The send this request collapsed onto. Equal to `id`.| |`original_created_at`|When that send was created.| |`window_seconds`|`300` when an `auto_content` key matched on content. `null` for `explicit` and `function_trigger` keys, which have no window, and for a `parent_already_replied` replay.| If you see `key_source: "auto_content"` on a send you meant to deliver, nothing was sent for that request: send it again with its own `Idempotency-Key`. When the earlier send had failed, the replay returns that stored error with its original status (`500`, `502`, `503` or `504`) instead of a `200`. The same `idempotency` object is then under `error.details`, next to `idempotent_replay: true` and `dedup_reason`, and the `Idempotent-Replayed` header is still set. That header is how you tell an earlier send's stored failure from a new attempt that failed. A stored failure does not always mean nothing was delivered. After `outbound_relay_timeout` the message may still have gone out, and the key is kept precisely so a retry cannot send a second copy. Before sending again under a new key, check the original with `GET /v1/sent-emails/{id}` using `original_sent_email_id`, and use a new key only when that send is known not to have been delivered. A keyless reply has one more rule. If the inbound email it answers already has a reply from you that is queued, in flight, delivered or of unknown outcome, the second reply is not sent even when its content differs. That replay carries `dedup_reason: "parent_already_replied"` and `window_seconds: null`, since no window is involved. The rule covers `POST /v1/emails/{id}/reply` and any `/v1/send-mail` or batch message whose `in_reply_to` names an inbound email you received. It does not cover an `fyi: true` acknowledgement or a send made with an agent connection key. An earlier reply that failed, bounced or was deferred does not count, so fixing a failed reply and sending it again goes out. Pass an `Idempotency-Key` to send a second reply to the same email. Sends made inside a Function are keyed differently when they carry no key: per Function and per inbound email, with no time window. See [Idempotency and Retries](/docs/functions#idempotency-and-retries). ## Tags Attach up to **10** `{ "name": ..., "value": ... }` tags to a send to correlate and filter your send history: ```json { "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Order confirmed", "body_text": "Your order is on its way.", "tags": [ { "name": "category", "value": "order_confirmation" }, { "name": "order_id", "value": "order_12345" } ] } ``` Tags are metadata only. They never affect delivery, are never rendered into headers, and never appear on the transmitted message. They are stored on the send record and read back verbatim as `tags` on `GET /v1/sent-emails/{id}`. Rules: * at most **10** tags per send; * names are capped at **64** characters and values at **256**; * names and values both use an ASCII token charset: letters, digits, underscores, and dashes only, and neither may be empty (for a bare marker tag, supply an explicit value such as `"true"`); * duplicate names are rejected, case-sensitively, so `Env` and `env` are two distinct names. ## Wait Mode `wait: true` makes the call block until Primitive has a delivery outcome from the receiving side. This is useful for agents, tests, and transactional workflows that need immediate status. Without `wait`, the API returns after accepting the outbound message. Read later state from `/v1/sent-emails` or the SDK equivalent. Wait responses can include `status`, `delivery_status`, `smtp_response_code`, and `smtp_response_text` (`smtp_enhanced_status_code` appears only on the stored row read back from `/v1/sent-emails`). `delivery_status` values are `delivered`, `bounced`, `deferred`, or `wait_timeout`; top-level `status` can also report states such as `gate_denied` or `agent_failed`. `deferred` is temporary: the relay keeps retrying, and the send later becomes `delivered`, or `bounced` with enhanced status `4.4.7` if the retry window ends first. A send whose final outcome is never reported becomes `unknown`. The wait timeout defaults to 30000 ms. If you override it with `wait_timeout_ms`, use a value from 1000 to 30000 ms. ### Response vs Stored Row The SDK/API response and the durable `sent_emails` row can briefly disagree. With `wait: true`, Primitive returns the outbound agent's terminal `delivery_status` as soon as the SMTP response is known or the wait timeout elapses. The follow-up update that flips the stored row from `queued` to the same terminal status happens immediately after, but a transient write failure can push that catch-up onto a retry path. Trust the send response for the per-call outcome. Treat `GET /v1/sent-emails/{id}` as the durable record that may converge a few seconds later. ```typescript const result = await client.send({ from: 'support@example.com', to: 'alice@customer.com', subject: 'Order confirmed', bodyText: 'Your order is on its way.', wait: true, }); console.log(result.deliveryStatus, result.smtpResponseCode); // The stored row may briefly still show "queued" at this exact moment. ``` To watch the stored row catch up, poll `GET /v1/sent-emails/{id}` or use `primitive sending:get-sent-email --id ` until `status` reaches a terminal value. ## Attachments Attachments are sent inline in the JSON body. Each entry has a `filename`, an optional `content_type` (inferred from the filename when omitted), and a base64-encoded `content_base64`: ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Report", "body_text": "Attached.", "attachments": [ { "filename": "report.txt", "content_type": "text/plain", "content_base64": "cmVwb3J0Cg==" } ] }' ``` Limits per send: * up to **100** attachments; * **30 MiB** total raw (decoded) across all attachments; * each `content_base64` field is capped at \~42 MiB of base64 text; * `filename` and `content_type` reject control characters (header-injection guard). ### Read sent attachments `GET /v1/sent-emails/{id}` includes submitted inline attachment metadata in `attachments`, the original decoded byte total in `attachments_size_bytes`, and `attachments_download_available`. Each metadata entry contains `filename`, `content_type`, `size_bytes`, `sha256`, `part_index`, and `tar_path`. Metadata may be present even when the archive is unavailable. Check `attachments_download_available` before offering a download. Download retained inline attachments with `GET /v1/sent-emails/{id}/attachments.tar.gz`. The response is a gzip-compressed tar archive; member names match the metadata's `tar_path`. Authenticate using your organization API key or an authorized OAuth token. A signed sent-attachment URL also works. Address-bound agent connection keys and Function credentials cannot download this archive. Legacy sends may not have a retained archive. Offloaded payload files are not included in this inline archive, so its contents may be smaller than the original attachment byte total. A missing archive returns `404`; discarded email content returns `410`. Archive availability does not prove the email was delivered. ### Inline Images (content\_id) Give an attachment a `content_id` to embed it inline and reference it from `body_html` with a `cid:` URL: ```json { "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Welcome", "body_html": "

Hello

\"Logo\"", "attachments": [ { "filename": "logo.png", "content_type": "image/png", "content_id": "logo", "content_base64": "iVBORw0KGgo..." } ] } ``` With `content_id` set, the part is composed inline (`multipart/related`, with a `Content-ID` header) so HTML-capable clients render it in place. Without it, the attachment behaves exactly as before. `content_id` must be 1-128 printable ASCII characters with no whitespace and no `<` or `>` (the composer adds the angle brackets); the `cid:` reference in your HTML must match it exactly. ## Scheduled Sends Pass `scheduled_at` to accept a send now and dispatch it later. The value is an ISO 8601 UTC datetime (`Z` suffix), strictly in the future, and at most **30 days** out. ```bash curl -X POST https://api.primitive.dev/v1/send-mail \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Renewal reminder", "body_text": "Your plan renews tomorrow.", "scheduled_at": "2026-08-01T09:00:00Z" }' ``` The response mirrors an async send with `status: "scheduled"` and the requested `scheduled_at` echoed. Nothing is dispatched yet: `queue_id` is `null` and `accepted`/`rejected` are empty. Within about a second of the scheduled time, Primitive dispatches the stored payload and the record moves through the normal statuses (`queued`, `submitted_to_agent`, then a delivery outcome). `scheduled_at` cannot be combined with: * `wait` (a connection cannot be held open for a future send); * `attachments` (not supported on scheduled sends yet; send them at delivery time or use an immediate send). ### Checks at Schedule Time vs Execution Time Creating a scheduled send runs the same validation and permission checks as an immediate send, but as a preflight for early feedback only. Gates and quota are evaluated, and quota is consumed, at execution time: passing the preflight is not permission to send later. If, when the send comes due, the from-domain has lost sendability, a recipient is no longer allowed, the credential the send was scheduled with has been revoked, or a usage limit is exhausted, the record lands at `gate_denied` with the denial recorded in `error_code` and `error_message`, and is not retried. Send rate limits are the exception: creating the scheduled send consumes your send budget at schedule time, not at execution. ### Rescheduling and Canceling Move a still-scheduled send with `PATCH /v1/sent-emails/{id}` (same bounds: strictly future, at most 30 days out), or cancel it with `POST /v1/sent-emails/{id}/cancel`. Both return the updated sent-email record. Cancellation is terminal (`status: "canceled"`); a canceled send never dispatches. ```bash curl -X PATCH https://api.primitive.dev/v1/sent-emails/ \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{"scheduled_at": "2026-08-02T09:00:00Z"}' curl -X POST https://api.primitive.dev/v1/sent-emails//cancel \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" ``` Both operations succeed only while the record is still `scheduled`. Once execution has claimed it, or it was already canceled or executed, they return `409` with the `not_scheduled` error code and a message naming the current status. Exactly one of reschedule, cancel, or execution wins on any given record. Scheduled and canceled sends are visible on `GET /v1/sent-emails` (filter with `status=scheduled` or `status=canceled`). Records keep `scheduled_at` after execution as the historical schedule, and canceled records carry `canceled_at`. Two idempotency notes specific to scheduling: `scheduled_at` is part of the request's content hash, so the same payload scheduled for two different times creates two distinct sends, while a retry of an identical scheduled request replays the original. Rescheduling does not recompute that hash, so a fresh `POST /v1/send-mail` with the same payload and the record's new time creates a second, separate send rather than replaying the rescheduled one. ## Rate Limits Outbound limits are plan and account dependent. When rate limited, `/v1/send-mail` returns `429` with the `rate_limited` error code and a structured error envelope. Branch on the `429` status and honor `Retry-After`. See [Errors](/docs/errors#rate-limit-responses). ## Batch sending Send many independent messages in one request with `POST /v1/send-mail/batch`. Each message runs through the exact same path as a single `POST /v1/send-mail` — same auth, validation, recipient gates, rate limiting, and idempotency — so the per-message semantics are identical. A batch is a convenience for fan-out, not a different send mode. The body is a `messages` array, where each entry is a normal send payload (`from`, `to`, `subject`, `body_text`/`body_html`, attachments, headers, and so on): ```bash curl -X POST https://api.primitive.dev/v1/send-mail/batch \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messages": [ { "from": "support@yourdomain.com", "to": "alice@example.com", "subject": "Order confirmed", "body_text": "Your order is on its way." }, { "from": "support@yourdomain.com", "to": "bob@example.com", "subject": "Order confirmed", "body_text": "Your order is on its way." } ] }' ``` A batch must contain between **1** and **100** messages; an empty `messages` array or one over the cap is rejected with a `validation_error` before any message is sent. The response is a `200` envelope whose `data` carries `count` and a `results` array with one entry per input message, in order. **One message failing does not fail the others** — each entry independently reports `success` with either its own `data` (the per-send result) or an `error`: ```json { "success": true, "data": { "count": 2, "replayed_count": 0, "results": [ { "index": 0, "success": true, "data": { "id": "..." } }, { "index": 1, "success": false, "error": { "code": "recipient_not_allowed", "message": "..." } } ] } } ``` Because the top-level status is `200` even when individual messages fail, always inspect each `results` entry's `success` flag rather than relying on the HTTP status. A batch-level `Idempotency-Key` header is not applied per message; each message instead derives its own idempotency from its payload, so set per-message keys by retrying individual sends if you need exactly-once guarantees. That means the [keyless rule](#sends-without-a-key) applies to every message in a batch: two messages with identical content to the same recipients, in one batch or in two batches inside the same 5-minute window, are one send. The duplicate's entry reports `success: true` with the first send's `id`, `idempotent_replay: true` and an `idempotency` object with `key_source: "auto_content"`. If the first send had failed, the duplicate's entry is `success: false` with that stored error, and the same fields are under `error.details`. The batch also reports the total of both kinds as `data.replayed_count`, and when it is above zero the response carries `Idempotent-Replayed: true` and `Idempotent-Replayed-Count: ` headers. To send identical messages on purpose, send them individually with `POST /v1/send-mail` and a distinct `Idempotency-Key` each. ## Related Pages * [Domains](/docs/domains): configure a custom outbound identity. * [REST API](/docs/api): endpoint list and envelope shape. * [SDKs](/docs/sdks): high-level send, reply, and forward helpers. * [Simple Email](/docs/simple-email): a drop-in send surface compatible with a widely used JavaScript email SDK's wire format. # Signature Verification URL: https://www.primitive.dev/docs/signature-verification Verify Primitive webhook signatures with SDK helpers or the manual HMAC-SHA256 flow. Primitive signs outbound webhook deliveries so your endpoint can verify that the payload came from Primitive and was not modified in transit. The header is: ```text Primitive-Signature: t=,v1= ``` Verify the exact raw request body. Do not parse JSON and re-stringify it before verification. ## SDK Helpers **Node.js** ```typescript import { verifyWebhookSignature, WebhookVerificationError, PRIMITIVE_SIGNATURE_HEADER, } from '@primitivedotdev/sdk/api'; export async function POST(req: Request) { const rawBody = await req.text(); const signatureHeader = req.headers.get(PRIMITIVE_SIGNATURE_HEADER) ?? ''; try { await verifyWebhookSignature({ rawBody, signatureHeader, secret: process.env.PRIMITIVE_WEBHOOK_SECRET!, }); } catch (error) { if (error instanceof WebhookVerificationError) { return new Response('invalid signature', { status: 401 }); } throw error; } return Response.json({ ok: true }); } ``` **Python** ```python import os from primitive import WebhookVerificationError, verify_webhook_signature async def handle(request): raw_body = await request.body() signature_header = request.headers.get('Primitive-Signature', '') try: verify_webhook_signature( raw_body=raw_body, signature_header=signature_header, secret=os.environ['PRIMITIVE_WEBHOOK_SECRET'], ) except WebhookVerificationError: return {'status': 401, 'body': 'invalid signature'} return {'ok': True} ``` **Go** ```go body, _ := io.ReadAll(r.Body) signatureHeader := r.Header.Get("Primitive-Signature") _, err := primitive.VerifyWebhookSignature(primitive.VerifyOptions{ RawBody: body, SignatureHeader: signatureHeader, Secret: os.Getenv("PRIMITIVE_WEBHOOK_SECRET"), }) if err != nil { http.Error(w, "invalid signature", http.StatusUnauthorized) return } ``` ## Manual Flow 1. Parse the `t` and `v1` parts of `Primitive-Signature`. 2. Reject timestamps outside your tolerance window. 3. Build the signed payload as `.`. 4. Compute HMAC-SHA256 with your webhook signing secret. 5. Compare the expected hex digest to `v1` using a constant-time comparison. ## Common Mistakes * Reading `request.json()` before verification. * Verifying a re-serialized JSON string instead of raw bytes. * Comparing strings with normal equality instead of a timing-safe comparison. * Returning 4xx for event types you intentionally ignore. Verify first, then return 2xx for ignored but trusted events. ## Related Pages * [Receiving Mail](/docs/receiving): webhook delivery behavior. * [Webhook Payload](/docs/webhook-payload): event schema and fields. * [SDKs](/docs/sdks): high-level receive helpers. # Simple Email URL: https://www.primitive.dev/docs/simple-email A drop-in send surface under /v1/simple-email that speaks the wire format of a widely used JavaScript email SDK. Point the SDK's baseUrl at Primitive and keep your send code unchanged. Simple Email is a compatibility wire format: a set of endpoints under `/v1/simple-email` whose request and response shapes match a widely used JavaScript email SDK. If your application already sends through that SDK, point its `baseUrl` at Primitive and keep your send code as it is: ```typescript const client = new EmailSdk(process.env.PRIMITIVE_API_KEY!, { baseUrl: 'https://api.primitive.dev/v1/simple-email', }); const { data, error } = await client.emails.send({ from: 'support@yourdomain.com', to: 'alice@example.com', subject: 'Order confirmed', text: 'Your order is on its way.', }); ``` Every send dispatches through the exact same path as [`POST /v1/send-mail`](/docs/sending): same authentication, validation, [recipient gates](/docs/sending#who-you-can-send-to), rate limits, and send records. Simple Email is a translation layer, not a second send pipeline, so a send made here shows up on `GET /v1/sent-emails` like any other. ## Base URL and Authentication ```text https://api.primitive.dev/v1/simple-email ``` Authentication is a normal Primitive credential: `Authorization: Bearer prim_`. There is no separate key type for this surface. ## Endpoints |Method and path|Purpose| |--|--| |`POST /v1/simple-email/emails`|Send one email. Returns `200` with a bare `{ "id": ... }`.| |`POST /v1/simple-email/emails/batch`|Send up to 100 emails in one request (bare JSON array body).| |`GET /v1/simple-email/emails/{id}`|Read a sent email back in the compat shape.| |`PATCH /v1/simple-email/emails/{id}`|Reschedule a still-scheduled send (`{ "scheduled_at": ... }`).| |`POST /v1/simple-email/emails/{id}/cancel`|Cancel a still-scheduled send.| ## Response and Error Shapes This surface does not use the standard Primitive `{ success, data, error }` envelope. Success bodies are the bare compat objects (`{ "id": ... }` on send, `{ "object": "email", "id": ... }` on reschedule and cancel), and errors are: ```json { "name": "invalid_parameter", "message": "`template` is not supported by this endpoint", "statusCode": 422 } ``` `statusCode` mirrors the HTTP status, so SDK clients can branch without reading the status line. Rate-limited responses keep their `Retry-After` and `RateLimit-*` headers. Internal error codes are mapped to the compat vocabulary; the ones you are most likely to branch on: |Compat `name`|Status|When| |--|--|--| |`invalid_parameter`|422|Validation failure, or an unsupported field (see below). Also used at 403 for a gated recipient (the message names the denied address) and at 409 for a reschedule or cancel that lost to execution.| |`missing_required_field`|422|`from`, `to`, `subject`, or both of `text`/`html` missing.| |`invalid_api_key`|401|Missing or invalid bearer token.| |`invalid_from_address`|403|The `from` domain is not a verified outbound sender for your org.| |`restricted_api_key`|403|Outbound mail is not enabled for this account.| |`invalid_access`|403|A usage cap or credit gate refused the send.| |`invalid_idempotency_key`|409|Idempotency conflict.| |`rate_limit_exceeded`|429|Rate limited; honor `Retry-After`.| |`not_found`|404|No email with this id is visible to your org.| |`application_error`|500/5xx|Unexpected internal failure.| ## Sending `POST /v1/simple-email/emails` accepts these fields: * `from`, `to`, `subject` (required), plus at least one of `text` or `html`; * `cc`, `bcc`, `reply_to` (string or array of strings, like `to`); * `headers` (same [allowlist](/docs/sending#custom-headers) as `/v1/send-mail`); * `tags` (same [rules](/docs/sending#tags) as `/v1/send-mail`); * `scheduled_at` (same [bounds](/docs/sending#scheduled-sends): strictly future, at most 30 days out); * `attachments`, each with `filename`, `content` (a base64 string), and optional `content_type` and `content_id` (note the field is `content` here, not `content_base64`). Field semantics are enforced by the canonical send-mail validator after translation, so address syntax, the header allowlist, tag charset, recipient caps, and scheduled-send bounds behave exactly as documented on [Sending Mail](/docs/sending). Sends on this surface are always asynchronous; there is no equivalent of `wait`. ### Unsupported Fields Return 422 Any field outside the supported set is rejected with a `422` naming the field, including compat SDK features Primitive deliberately does not implement here (for example `template` and `topic_id`). This is by design: SDKs on this wire format are commonly configured with a fallback provider, and a `422` routes the message through the fallback instead of failing the send outright. The same applies inside `attachments`: remote-file attachments (`path`) are rejected with a `422`. Buffer-object `content` (a JSON-serialized Node Buffer) is accepted and converted to base64 for you; a plain base64 string works too. ## Reading a Send Back `GET /v1/simple-email/emails/{id}` returns the compat read-back shape: ```json { "object": "email", "id": "...", "to": ["alice@example.com"], "from": "support@yourdomain.com", "subject": "Order confirmed", "html": null, "text": "Your order is on its way.", "created_at": "2026-07-17T12:00:00.000Z", "last_event": "delivered", "bcc": null, "cc": null, "reply_to": null, "message_id": "<...>", "scheduled_at": null } ``` `last_event` collapses Primitive's internal statuses into the compat vocabulary: `queued`, `sent`, `delivered`, `bounced`, `delivery_delayed`, `failed`, `scheduled`, or `canceled`. Statuses without a direct equivalent report `sent` (the send was accepted; the compat vocabulary cannot say more). For the full-fidelity record, read the same id from `GET /v1/sent-emails/{id}`. ## Rescheduling and Canceling `PATCH /v1/simple-email/emails/{id}` with `{ "scheduled_at": ... }` moves a still-scheduled send; `POST /v1/simple-email/emails/{id}/cancel` cancels one. Both acknowledge with `{ "object": "email", "id": ... }` and carry the same compare-and-swap semantics as the [canonical endpoints](/docs/sending#rescheduling-and-canceling): once the send has executed or was already canceled, they return a `409` whose message names the current status. ## Batch Sending `POST /v1/simple-email/emails/batch` takes a bare JSON array of 1 to 100 send bodies and answers with the accepted ids in input order: ```json { "data": [ { "id": "..." }, { "id": "..." } ] } ``` Unlike [`POST /v1/send-mail/batch`](/docs/sending#batch-sending), which reports per-message results, this surface reports a single batch-level outcome, so it is adapted as all-or-nothing in two layers: 1. **Validate before dispatch.** Every item is translated and validated up front. Any invalid item rejects the whole batch with a `422` naming the item index (`emails[3]: ...`) and zero sends dispatched, so a validation failure is never partially accepted. 2. **Serial fail-fast dispatch.** Items are dispatched one at a time, in order, and the first failure returns immediately; nothing after the failing item is ever dispatched. What serial dispatch cannot undo: items accepted before the failing one are already queued for delivery and cannot be recalled. The batch error message names the failing item's index and the count of already-accepted items, and tells you not to re-send the full batch; retry only the items from the failing index onward, or route just those through your fallback. Two more batch behaviors to know: * A batch-level `Idempotency-Key` header is applied per item with an index suffix (`:`), so retrying the same batch with the same key replays each item instead of sending it again. The suffixed key has to fit the 255-character key limit, so the longest batch key accepted is 253 characters for a batch of 1 to 10 emails and 252 characters for 11 to 100. A longer key is rejected with a `422` `invalid_parameter` error and nothing in the batch is sent. A key over 255 characters fails the general key-length check first, so its message names the 255-character limit; a key of 255 or fewer that is still too long for the batch gets a message stating the maximum for that batch size. Keep a batch key to 252 characters or fewer and it fits any batch. When no header is supplied, each item derives its own key from its payload; in that case, byte-identical duplicate items within one batch dedupe to a single send and echo the same id in each slot. The same holds for a keyless single send: an identical send inside the same 5-minute window is not sent again and returns the first send's id (see [Sends without a key](/docs/sending#sends-without-a-key)). Response bodies on this surface keep their compat shape, so a replay is reported in headers: `Idempotent-Replayed: true` on any response where a send was answered by an earlier one, plus `Idempotent-Replayed-Count: ` on a batch. * Serial dispatch means latency grows linearly with batch size. Throughput-sensitive callers should prefer `POST /v1/send-mail/batch`, whose per-item results avoid whole-batch retries. ## Related Pages * [Sending Mail](/docs/sending): the canonical send surface and full field semantics. * [REST API](/docs/api): the standard envelope the rest of the v1 API uses. * [Errors](/docs/errors): the canonical error-code taxonomy these compat errors map from. # Skills & Agent Signup URL: https://www.primitive.dev/docs/skills Install the Primitive email skills, or create a standalone agent account with its own inbox. Primitive ships skills for coding agents (Claude Code, Codex, Cursor, and 50+ compatible agents) so that emailing — sending and receiving — is one verb away, with no SMTP credentials and no API setup in the agent's context. Install the bundle with: ```bash npx skills add primitivedotdev/skills ``` In Claude Code, the same repository installs as a plugin that carries the skills together with Primitive's hosted [MCP server](/docs/connectors): ```bash claude plugin marketplace add primitivedotdev/skills claude plugin install primitive@primitive ``` Use one or the other, not both, so each skill is installed once. The chat and inbox skills below are browsable at [skills.sh/primitivedotdev/skills](https://skills.sh/primitivedotdev/skills). ## primitive-chat — send and wait for the reply `primitive-chat` is for when your agent needs to contact someone (a person or another agent) and get the answer back. It sends your outbound message and waits for the threaded reply in one step: ```bash primitive chat help@vendor.example "how do I install the prose skill?" ``` Continue the same thread, or get machine-readable output: ```bash primitive chat reply "one more thing" primitive chat help@vendor.example "..." --json ``` Reach for it whenever you would open a `mailto:`, ask the user for SMTP credentials, or want to ask a help/dev/support agent the same way you would grep its docs. ## primitive-inbox — receive mail and act on it `primitive-inbox` gives your agent a managed `*.primitive.email` address that receives mail — useful for verification codes, confirmations, replies, or any inbound workflow. Read and wait for mail: ```bash primitive emails latest primitive emails wait ``` To run code on every inbound message, deploy a hosted [Function](/docs/functions) and then bind your inbound mail to it (deploying alone does not route mail to the function): ```bash primitive functions deploy --name my-handler --file ./dist/handler.js # bind inbound mail to the function (use the function id from the deploy output) primitive functions route-set --id --fallback ``` ## Standalone agent signup An agent can create a separate Primitive account and managed inbox without an API key or manual review. A human who controls the signup email must provide one verification code. This path does not add the agent to an existing organization's app or agent network. To connect a coding session to your organization, create a new agent in the Primitive app and give that session its single-use setup instruction. Start signup with an email the user controls: ```bash primitive agent start-agent-signup --email you@example.com --terms-accepted ``` Primitive emails a 6-digit verification code to that address, and the command's output includes a `signup_token` (a session handle, not a credential). Complete signup with both: ```bash primitive agent verify-agent-signup --verification-code "$CODE" --signup-token "$SIGNUP_TOKEN" ``` On success the CLI stores OAuth credentials locally and your agent has a managed `.primitive.email` address. Confirm with: ```bash primitive whoami primitive domains list ``` If the code expires or never arrives, reissue it (reuse the same `signup_token` — do not restart): ```bash primitive agent resend-agent-signup-verification --signup-token "$SIGNUP_TOKEN" ``` The signup yields a managed inbox suited to agent-to-agent mail. To turn it into a full account — for example to add a custom domain — upgrade by confirming an email: ```bash primitive agent upgrade --email you@example.com ``` See [Authentication & API Keys](/docs/auth) for how the resulting credentials work, and [Which onboarding fits?](/docs/which-onboarding) to decide between the agent-side and product-side paths. ## Related Pages * [Quickstart](/docs/quickstart): sign up, deploy a Function, send and receive mail. * [CLI](/docs/cli): the full `primitive` command surface. * [Sending Mail](/docs/sending) and [Receiving Mail](/docs/receiving): the underlying send and receive model. * [Authentication & API Keys](/docs/auth): credentials and OAuth. # API versioning and deprecation policy URL: https://www.primitive.dev/docs/versioning How the Primitive REST API is versioned, what counts as a breaking change, and the deprecation and sunset policy for removing operations and fields. The Primitive REST API uses URL path versioning. The current and only supported major version is **v1**, served at `https://api.primitive.dev/v1`. ```bash curl https://api.primitive.dev/v1/account \ -H "Authorization: Bearer $PRIMITIVE_AUTH_TOKEN" ``` ## What's covered by the v1 contract Within the v1 surface, these are considered part of the contract and changes to them require a new major version: * The set of operation paths, HTTP methods, and operation IDs published in the OpenAPI spec at `/openapi.yaml`. * The shape of request bodies (required fields, types, accepted enum values). * The shape of response bodies (field names, types, presence guarantees). * The error envelope: `{ success, error: { code, message, request_id, details? } }` and the set of `error.code` values documented at [Errors](/docs/errors). * Auth model: bearer token in `Authorization` header, prefixes (`prim_`, `prim_oat_`), 401 challenge shape (`WWW-Authenticate: Bearer realm="Primitive API", resource_metadata="..."`). * Webhook delivery contract: `Primitive-Signature` header, HMAC-SHA256 signing scheme, signed-string format (`${timestamp}.${rawBody}`). ## What's allowed without a new major version These are explicitly NOT breaking changes: * Adding new operations. * Adding new optional request fields. * Adding new response fields (clients must tolerate unknown fields). * Adding new `error.code` values for new failure modes (clients must treat unrecognized codes as a generic error and not crash). * Adding new enum values to fields whose schema does not say `additionalValues: false`. * Tightening server-side validation that was previously lax (e.g., adding a length cap to a string field that previously had none). Clients that send values within previously-documented limits are unaffected. ## Deprecation policy When a v1 operation, field, or behavior is on a path to removal: 1. **Announcement**: the operation or field is marked `deprecated: true` in the OpenAPI spec, with a `description` calling out the replacement. 2. **Soft warning**: deprecated requests continue to work and their responses carry a `Deprecation` header per RFC 9745 (the date it was deprecated, as `@`) and `Link: ; rel="deprecation"`. Once a removal date is scheduled they also carry a `Sunset` header per RFC 8594 with that date. For a deprecated request-body field, the headers are sent on responses to requests that include the field, once the body has been read (a `401` or `429` answered before that does not carry them). 3. **Minimum sunset window**: breaking changes are announced at least 6 months before removal. Removal lands in v2, never in v1. There are no scheduled removals in v1 today. One field is deprecated without a removal date: `outbound` on `POST /v1/domains` is accepted and ignored, because outbound DNS is provisioned for every new domain claim. Requests that send it receive the `Deprecation` and `Link` headers. ## What constitutes v2 If we ship a v2 surface, it will: * Live at `https://api.primitive.dev/v2` (new path prefix; v1 continues to serve at `/v1` until its own sunset). * Be announced at least 6 months before v1 is sunset, with the v1 sunset date published in the OpenAPI spec via `info.x-sunset-date` and on the [status page](/status). * Ship with an SDK + CLI release tagged for the v2 surface, plus a migration guide linking field-by-field. There is no scheduled v2 release. ## GraphQL versioning Primitive also publishes a public, read-only GraphQL surface at `https://api.primitive.dev/v1/graphql`. It returns synthetic sample data for schema exploration; the authenticated REST API remains the system of record. The GraphQL schema is versioned through the `apiVersion` field, currently `v1`. Changes within a version are additive and backward-compatible. Fields scheduled for removal are annotated with GraphQL's standard `@deprecated` directive, including a reason and removal date, and remain available until that date. Breaking schema changes ship under a new version, never silently inside `v1`. Agents and scanners that need the authored schema should fetch the SDL at `https://api.primitive.dev/v1/graphql.graphql`. The SDL includes the `@deprecated` annotations, typed-error result unions, and the inline schema-evolution policy. Introspection is enabled; use `fields(includeDeprecated: true)` when checking for deprecated fields, because GraphQL introspection hides deprecated fields by default. The [GraphQL API guide](/docs/graphql#versioning-and-deprecation-policy) lists the fields deprecated today. ## How agents should declare a target version The bearer-authenticated v1 surface does not require an `Accept: application/vnd.primitive.v1+json` header today; the URL path is the version selector. If you want a defensive header anyway, send: ```http Accept: application/json, application/vnd.primitive.v1+json ``` The API ignores unknown vendor types in the Accept header. ## OpenAPI spec versioning The shipped spec — canonical at `https://api.primitive.dev/v1/openapi` (also served as `/openapi.json` and `/openapi.yaml`) — carries an `info.version` field. That number tracks the spec's editorial revisions (added descriptions, doc-only changes, augmented examples), not the API contract. The contract version is fixed at v1 until/unless a v2 ships. # Wake URL: https://www.primitive.dev/docs/wake Schedule and send typed "wake" commands to your own Primitive Functions over DKIM-signed email. Cron-driven or one-shot, gated by a default-deny allowlist of which senders may wake which function. Wake lets you drive your own [Primitive Functions](/docs/functions) on a schedule, or on demand, by sending them a typed command over signed email. A wake is just a structured email your function receives like any other inbound message, except it carries a command (for example `process_inbox`) and an optional JSON `args` object, and it is authenticated end to end with DKIM. Two things make wake more than "email yourself on a cron": * **Typed delivery.** A wake is delivered to your function as a typed `interaction.wake.dispatched` event, not a raw message you have to parse. Your handler branches on the command and reads `args`. * **Default-deny authorization.** A function is woken only by senders you have explicitly allowed. With no grants, nothing can wake it, not even your own other addresses. Wake is built on the Primitive Interactions standard (`wake.dispatch/1`): every wake carries a DKIM-signed `X-Primitive-Interaction` classifier header, and inbound wakes run through the same trust engine as any other interaction. **Availability:** Wake is enabled per organization. Until it is turned on for your org, the Wake API and dashboard surfaces return a `403`. ## The model A wake moves through a small, typed lifecycle: 1. **dispatch** sends a wake command to the function's address (on a schedule, or one-shot). 2. **awaiting\_result** means the function has received the command and is expected to act. 3. **result / reject / expire** is the terminal state: the function reports a result, rejects the command, or the wake expires unanswered. Each transition emits an event you can subscribe to: |Event|When| |--|--| |`interaction.wake.dispatched`|A wake was authorized and delivered to the function| |`interaction.wake.completed`|The function reported a result| |`interaction.wake.rejected`|The function rejected the wake (a wake denied by the trust gate is dropped silently and emits no event)| |`interaction.wake.expired`|The wake was not answered before it expired| The `interaction.wake.dispatched` event is the load-bearing one: it is emitted only after authorization passes, and it is what your function endpoint actually receives. ## Authorize who can wake a function Wake is default-deny. Before a sender can wake a function, you grant it explicitly. A grant is scoped by: * **Sender domain** (required): the domain the wake must come from, for example `agents.acme.dev`. * **Sender address** (optional): narrow the grant to a single address, for example `scheduler@agents.acme.dev`. Omit to allow any address on the domain. * **Allowed commands** (optional): restrict the grant to specific commands. Omit to allow any command. A command is matched exactly, so list each one you intend to allow. Manage grants from the **Wake** tab on a function's page in the dashboard (which resolves the function's receiving endpoint for you), or with the Wake API: `POST /v1/wake/authorizations` (passing the receiving endpoint id, the sender domain, and optionally a single address and a list of allowed commands) and `GET /v1/wake/authorizations`. To wake your own function from your own scheduler, grant the address your schedules send **from** (see below). A wake whose sender is not covered by an enabled grant is denied before delivery. ## Schedule a wake A schedule sends a wake command to a target address on a cron expression, in a timezone you choose. |Field|Meaning| |--|--| |`from_address`|The address the wake is sent from (must be authorized to wake the target).| |`target_address`|The function's inbound address that receives the wake.| |`command`|The typed command, for example `process_inbox`.| |`args`|Optional JSON object passed to the handler (capped at 16 KB).| |`cron_expr`|Standard cron expression for when to fire.| |`timezone`|IANA timezone the cron is evaluated in, for example `America/New_York`.| `from_address` and `target_address` must differ; a schedule cannot wake the address it sends from. Create and manage schedules from **Settings → Wake** in the dashboard, or with the Wake API: `POST /v1/wake/schedules` (with the fields above), `GET /v1/wake/schedules`, and `POST /v1/wake/schedules/:id/run` to fire one once, off-schedule. ## Inspect dispatches Every wake that fires is recorded. List recent dispatches and their outcomes from the dashboard's **Wake** surfaces — which show each schedule's next run and each dispatch's status (dispatched, completed, rejected, expired) — or with `GET /v1/wake/dispatches`. ## Handle a wake in your function A woken function receives an `interaction.wake.dispatched` event. The event name arrives in the `X-Webhook-Event` header; branch on it and run the work the wake represents: ```js export default { async fetch(request) { if (request.headers.get("X-Webhook-Event") === "interaction.wake.dispatched") { // Run the scheduled work. The command and args you set on the schedule // (or one-shot dispatch) determine what this wake should do. await processInbox(); } return new Response("ok"); }, }; ``` The wake is signed and trust-checked before it reaches you: if the event arrived, the sender was authorized for that function and command. ## API Everything above is available over the [API](/docs/api) under `/v1/wake`: * `GET/POST /v1/wake/schedules`, `GET/PATCH/DELETE /v1/wake/schedules/:id`, `POST /v1/wake/schedules/:id/run` * `GET/POST /v1/wake/authorizations`, `PATCH/DELETE /v1/wake/authorizations/:id` * `GET /v1/wake/dispatches` # Webhook Payload URL: https://www.primitive.dev/docs/webhook-payload Inbound email event schema, outbound sent_email events, delivery metadata, parsed content fields, and example JSON payloads. Primitive delivers inbound mail as an event in the `email.*` family. The most common is `email.received`; machine-generated mail (delivery failures, TLS and DMARC reports) is delivered under a more specific event type instead. All inbound `email.*` events share the same payload shape, and the same shape is sent to self-hosted webhooks and hosted Primitive Functions. Mail you send has its own opt-in family, `sent_email.*`, with a different payload. See [Sent Email Events](#sent-email-events). The event name is delivered in the `X-Webhook-Event` request header, and the delivery id in `X-Webhook-Id`. Always read the event type from `X-Webhook-Event` (or the top-level `event` field) rather than assuming `email.received`. `email.received` is documented in full below; the other `email.*` events (next section) reuse the same fields, and the x402 payment and interaction events (at the end of this page) have their own payload shapes. ## Email Event Types Every inbound message is delivered under one of the `email.*` event types below. The type is resolved from the kind of message received: a delivery failure (DSN) arrives as `email.bounced`, an SMTP TLS report as `email.tls_report`, and so on. Anything that isn't one of these specialized kinds — ordinary mail, auto-replies, complaints — is delivered as `email.received`. |Event|When it fires| |--|--| |`email.received`|Regular inbound mail and any kind that isn't one of the specialized types below.| |`email.bounced`|A delivery failure / DSN (delivery status notification) for one of your sends.| |`email.tls_report`|An SMTP TLS report (RFC 8460) addressed to your domain.| |`email.dmarc_report`|A DMARC aggregate report (RFC 7489) addressed to your domain.| |`email.dmarc_failure`|A DMARC failure (forensic / RUF) report addressed to your domain.| All inbound `email.*` events use the same payload documented below. The `email.bounced`, `email.tls_report`, and `email.dmarc_report` types additionally carry a matching block under `email.analysis` (see [Analysis](#analysis)) with the parsed report or bounce details. `email.dmarc_failure` carries no extra analysis block. An endpoint receives only the event types it is subscribed to. Endpoints created before subscriptions existed, and endpoints that leave the subscription unset, receive every inbound `email.*` event (but never `sent_email.*` events, which must be listed). To receive `email.bounced` (or any other specialized type) on a newer endpoint, subscribe it via the endpoint's `rules.event_types`. See [Endpoints](/docs/endpoints). ## Top-Level Shape This is the actual `email.received` payload shape Primitive sends to webhook endpoints and Primitive Functions. ```json { "id": "evt_0d4ce769a6f5822afa37417473d33ff8ae0254f280802d94f4eff284cde30567", "event": "email.received", "version": "2025-12-14", "delivery": { "endpoint_id": "ep_01HZYABCDEF1234567890", "attempt": 1, "attempted_at": "2026-01-01T00:00:05.000Z" }, "email": { "id": "em_01HZYABCDEF1234567890", "received_at": "2026-01-01T00:00:00.000Z", "smtp": { "helo": "mail.example.com", "mail_from": "bounce@example.com", "rcpt_to": [ "support@your-org.primitive.email" ] }, "headers": { "message_id": "", "subject": "Need help", "from": "Alice ", "to": "Support ", "date": "Thu, 01 Jan 2026 00:00:00 +0000" }, "content": { "raw": { "included": true, "encoding": "base64", "max_inline_bytes": 262144, "size_bytes": 257, "sha256": "8bf8f3c56544d3e56ac84f414754674d51c55d6504d847a9868af46ab65d4f0f", "data": "RnJvbTogQWxpY2UgPGFsaWNlQGV4YW1wbGUuY29tPg0KVG86IFN1cHBvcnQgPHN1cHBvcnRAeW91ci1vcmcucHJpbWl0aXZlLmVtYWlsPg0KQ2M6IEJvYiA8Ym9iQGV4YW1wbGUuY29tPg0KUmVwbHktVG86IEFsaWNlIFN1cHBvcnQgPHJlcGx5QGV4YW1wbGUuY29tPg0KU3ViamVjdDogTmVlZCBoZWxwDQpEYXRlOiBUaHUsIDAxIEphbiAyMDI2IDAwOjAwOjAwICswMDAwDQpNZXNzYWdlLUlEOiA8bWVzc2FnZS1pZEBleGFtcGxlLmNvbT4NCg0KSGVsbG8=" }, "download": { "url": "https://api.primitive.dev/v1/emails/em_01HZYABCDEF1234567890/raw?token=download-token", "expires_at": "2026-01-02T00:00:00.000Z" } }, "parsed": { "status": "complete", "error": null, "body_text": "Hello", "body_html": "

Hello

", "reply_to": [ { "address": "reply@example.com", "name": "Alice Support" } ], "cc": [ { "address": "bob@example.com", "name": "Bob" } ], "bcc": null, "to_addresses": [ { "address": "support@your-org.primitive.email", "name": "Support" } ], "in_reply_to": [ "" ], "references": [ "", "" ], "attachments": [ { "filename": "invoice.pdf", "content_type": "application/pdf", "size_bytes": 24576, "sha256": "b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3", "part_index": 0, "tar_path": "0_invoice.pdf" } ], "attachments_download_url": "https://api.primitive.dev/v1/emails/em_01HZYABCDEF1234567890/attachments.tar.gz?token=download-token" }, "analysis": { "spamassassin": { "score": 0.1 }, "sender": { "authenticated": true, "basis": "dmarc_aligned", "reasons": [ "DKIM signature aligned with the From domain" ] } }, "auth": { "spf": "pass", "dmarc": "pass", "dmarcPolicy": "reject", "dmarcFromDomain": "example.com", "dmarcSpfAligned": false, "dmarcDkimAligned": true, "dmarcSpfStrict": false, "dmarcDkimStrict": false, "dkimSignatures": [ { "domain": "example.com", "selector": "default", "result": "pass", "aligned": true, "keyBits": 2048, "algo": "rsa-sha256" } ] }, "thread_id": "thr_01HZYABCDEF1234567890", "automated": false, "automated_reasons": [] } } ``` ## Stable Identifiers The top-level `id` is stable across retries and manual replays to the same endpoint. Use it for webhook dedupe. `email.id` is the stored email id. Use it with REST endpoints such as `/v1/emails/{id}`, `/v1/emails/{id}/replay`, and `/v1/emails/{id}/reply`. ## Delivery Metadata The `delivery` object records the delivery attempt counter and endpoint information. `delivery.attempt` is a delivery-call counter; in the current delivery path it is `1` on the first delivery to an endpoint. Do not rely on it to distinguish a first delivery from a retry — the top-level `id` is preserved across retries and re-deliveries, so use it (not `attempt`) for dedupe. ## Parsed Content When parsing succeeds, `email.parsed.status` is `complete` and body fields are available. Large raw content and attachments can be stored separately and exposed through signed download URLs. If parsing fails, `email.parsed.status` is `failed`, `email.parsed.error` is populated, body and address fields are `null`, and attachments are empty or partial metadata only. ## Headers Headers preserve the important RFC 5322 fields needed for audit trails. Use `email.headers.message_id`, `email.parsed.in_reply_to`, `email.parsed.references`, and `email.thread_id` when building threading logic. ## Raw Content Small raw emails are included inline at `email.content.raw.data` as base64. If the raw email is larger than `email.content.raw.max_inline_bytes`, `email.content.raw.included` is `false` and the raw content must be fetched from `email.content.download.url` before `expires_at`. Attachments are listed as metadata in `email.parsed.attachments`. Download the files from `email.parsed.attachments_download_url` when it is not `null`. ## Auth And Analysis `email.auth` contains SPF, DMARC, and DKIM results from the inbound message. The SDKs turn these into decisions: `validateEmailAuth(event.email.auth)` computes an overall verdict, and `isTrustedSender(event, { domain })` anchors that verdict to an expected From domain for handlers that gate actions on the sender (a verdict alone does not say which domain authenticated). `email.analysis.sender` is Primitive's sender authentication verdict derived from those auth fields. `email.analysis.spamassassin` is present when spam scoring ran. `email.analysis.forward` is optional; in managed Primitive webhook deliveries it is currently included only when forward analysis detects forwarded content. Specialized event types attach an additional block under `email.analysis` with the parsed details: `email.analysis.bounce` on an `email.bounced` event (the classified delivery failure), `email.analysis.tls_report` on an `email.tls_report` event (the parsed RFC 8460 report), and `email.analysis.dmarc_report` on an `email.dmarc_report` event (the parsed RFC 7489 aggregate report). Each is present only when the corresponding report or bounce parsed successfully. ## Field Reference These descriptions are sourced from the webhook JSON schema used by the SDK, with the managed `email.analysis.sender` extension documented from the webhook delivery implementation. Required means required when its parent object or union variant is present. ### Top Level |Field|Type|Required|Description| |--|--|--:|--| |`id`|`string`|yes|Unique delivery event ID.| |`event`|`EmailEventType`|yes|Event type identifier: one of `"email.received"`, `"email.bounced"`, `"email.tls_report"`, `"email.dmarc_report"`, or `"email.dmarc_failure"`. Matches the `X-Webhook-Event` header. See [Email Event Types](#email-event-types).| |`version`|`WebhookVersion`|yes|API version in date format (YYYY-MM-DD). Use this to detect version mismatches between webhook and SDK.| |`delivery`|`object`|yes|Metadata about this webhook delivery.| |`email`|`object`|yes|The email that triggered this event.| |`routing`|`object`|no|The recipient-routing decision for this email: which endpoint it resolved to and why. Present only when recipient routing is enabled for your organization; omitted otherwise. See [Routing](#routing).| ### Delivery |Field|Type|Required|Description| |--|--|--:|--| |`delivery.endpoint_id`|`string`|yes|ID of the webhook endpoint receiving this event. Matches the endpoint ID from your Primitive dashboard.| |`delivery.attempt`|`integer`|yes|Delivery-call counter for this email. In the current delivery path it is `1` on an endpoint's first delivery. Do not treat it as a precise per-endpoint retry count or use it to detect a first delivery; dedupe on the top-level `id` instead.| |`delivery.attempted_at`|`string`|yes|ISO 8601 timestamp (UTC) when this delivery was attempted.| ### Routing Present only when recipient routing is enabled for your organization; omitted otherwise. It is the same decision recorded on the email and returned by [`/v1/routes/simulate`](/docs/routing), so you can see why a message reached this endpoint without a separate call. Only `outcome` is guaranteed to be present; treat the other fields as optional. |Field|Type|Required|Description| |--|--|--:|--| |`routing.outcome`|`"matched" \|"defaulted" \|"none"`|yes|How the destination was chosen: matched a recipient route, fell back to a default destination, or none resolved.| |`routing.endpoint_id`|`string \|null`|no|The endpoint the email was delivered to, or null when none resolved.| |`routing.matched_route_id`|`string \|null`|no|The recipient route that matched, or null when the outcome was not `matched`.| |`routing.matched_tier`|`"exact" \|"wildcard" \|"regex" \|null`|no|The match tier of the route that matched, or null when the outcome was not `matched`.| |`routing.default_scope`|`"domain" \|"org" \|null`|no|When the outcome was `defaulted`, whether the default came from the domain or the organization; null otherwise.| |`routing.version`|`integer`|no|Decision schema version, so you can detect shape changes.| ### Email And SMTP |Field|Type|Required|Description| |--|--|--:|--| |`email.id`|`string`|yes|Unique email ID in Primitive. Use this ID when calling Primitive APIs to reference this email.| |`email.thread_id`|`string \|null`|no|Conversation thread this email belongs to. Inbound and outbound messages in the same conversation share a thread\_id. Null on messages received before threading was enabled.| |`email.automated`|`boolean`|no|Whether the message was sent by a machine rather than a person: a bounce, an auto-reply, list or bulk mail, a report, or mail from the address it was delivered to. The same value `GET /v1/emails/{id}` returns, decided once when the email arrives. Use it to skip machine mail without fetching the email. Treat a missing value as unknown and fetch the email if you need it.| |`email.automated_reasons`|`string[]`|no|Why `email.automated` is true, in rule order; empty when it is false. Current values: `null_envelope_sender`, `no_identifiable_sender`, `own_address`, `mailer_daemon`, `auto_submitted`, `precedence`, `list_unsubscribe`, `list_id`, `auto_response_suppress`, `failed_recipients`, `report`. Treat an unfamiliar value as a reason added after your code was written. Present whenever `email.automated` is.| |`email.received_at`|`string`|yes|ISO 8601 timestamp (UTC) when Primitive received the email.| |`email.smtp`|`object`|yes|SMTP envelope information. This is the real sender and recipient info from the SMTP transaction, which may differ from headers.| |`email.smtp.helo`|`string \|null`|yes|HELO/EHLO hostname from the sending server. Null if not provided during SMTP transaction.| |`email.smtp.mail_from`|`string`|yes|SMTP envelope sender (MAIL FROM command). This is the bounce address, which may differ from the From header.| |`email.smtp.rcpt_to`|`string[]`|yes|SMTP envelope recipients (RCPT TO commands). All addresses that received this email in a single delivery.| |`email.headers`|`object`|yes|Parsed email headers. These are extracted from the email content, not the SMTP envelope.| |`email.headers.message_id`|`string \|null`|yes|Message-ID header value. Null if the email had no Message-ID header.| |`email.headers.subject`|`string \|null`|yes|Subject header value. Null if the email had no Subject header.| |`email.headers.from`|`string`|yes|From header value. May include display name: `"John Doe" `| |`email.headers.to`|`string`|yes|To header value. May include multiple addresses or display names.| |`email.headers.date`|`string \|null`|yes|Date header value as it appeared in the email. Null if the email had no Date header.| ### Raw Content |Field|Type|Required|Description| |--|--|--:|--| |`email.content`|`object`|yes|Raw email content and download information.| |`email.content.raw`|`RawContent`|yes|Raw email in RFC 5322 format. May be inline (base64) or download-only depending on size.| |`email.content.raw.included`|`boolean`|yes|Whether the raw content is included inline. `true` means `data` is present. `false` means download is required.| |`email.content.raw.encoding`|`"base64"`|when inline|Encoding used for the `data` field. Always `"base64"`.| |`email.content.raw.reason_code`|`"size_exceeded"`|when download-only|Reason the content was not included inline.| |`email.content.raw.max_inline_bytes`|`integer`|yes|Maximum size in bytes for inline inclusion. Emails larger than this threshold require download.| |`email.content.raw.size_bytes`|`integer`|yes|Actual size of the raw email in bytes.| |`email.content.raw.sha256`|`string`|yes|SHA-256 hash of the raw email content (hex-encoded). Use this to verify integrity after base64 decoding or download.| |`email.content.raw.data`|`string`|when inline|Base64-encoded raw email (RFC 5322 format). Decode with `Buffer.from(data, 'base64')` in Node.js.| |`email.content.download`|`object`|yes|Download information for the raw email. Always present, even if raw content is inline.| |`email.content.download.url`|`string`|yes|URL to download the raw email as-is in RFC 5322 format. Managed Primitive always issues HTTPS.| |`email.content.download.expires_at`|`string`|yes|ISO 8601 timestamp (UTC) when this URL expires. Download before this time or the URL will return 401.| ### Parsed Content |Field|Type|Required|Description| |--|--|--:|--| |`email.parsed`|`ParsedData`|yes|Parsed email content (body text, HTML, attachments). Check `status` to determine if parsing succeeded.| |`email.parsed.status`|`"complete" \|"failed"`|yes|Discriminant indicating whether parsing succeeded.| |`email.parsed.error`|`ParsedError \|null`|yes|Always null when parsing succeeds. Contains failure details when parsing fails.| |`email.parsed.error.code`|`"PARSE_FAILED" \|"ATTACHMENT_EXTRACTION_FAILED"`|when failed|Error code indicating the type of failure.| |`email.parsed.error.message`|`string`|when failed|Human-readable error message describing what went wrong.| |`email.parsed.error.retryable`|`boolean`|when failed|Whether retrying might succeed. If true, the error was transient. If false, the email itself is problematic.| |`email.parsed.body_text`|`string \|null`|yes|Plain text body of the email. Null if the email had no text/plain part or parsing failed.| |`email.parsed.body_html`|`string \|null`|yes|HTML body of the email. Null if the email had no text/html part or parsing failed.| |`email.parsed.reply_to`|`EmailAddress[] \|null`|yes|Parsed Reply-To header addresses. Null if the email had no Reply-To header or parsing failed.| |`email.parsed.cc`|`EmailAddress[] \|null`|yes|Parsed CC header addresses. Null if the email had no CC header or parsing failed.| |`email.parsed.bcc`|`EmailAddress[] \|null`|yes|Parsed BCC header addresses. Null if the email had no BCC header or parsing failed.| |`email.parsed.to_addresses`|`EmailAddress[] \|null`|yes|Parsed To header addresses. Null if the email had no To header or parsing failed.| |`email.parsed.reply_to[].address`|`string`|yes|The email address portion. This is the raw value from the email header with no validation applied.| |`email.parsed.reply_to[].name`|`string \|null`|yes|The display name portion, if present. Null if the address had no display name.| |`email.parsed.cc[].address`|`string`|yes|The email address portion.| |`email.parsed.cc[].name`|`string \|null`|yes|The display name portion, if present.| |`email.parsed.bcc[].address`|`string`|yes|The email address portion.| |`email.parsed.bcc[].name`|`string \|null`|yes|The display name portion, if present.| |`email.parsed.to_addresses[].address`|`string`|yes|The email address portion.| |`email.parsed.to_addresses[].name`|`string \|null`|yes|The display name portion, if present.| |`email.parsed.in_reply_to`|`string[] \|null`|yes|In-Reply-To header values (Message-IDs of the email or emails being replied to).| |`email.parsed.references`|`string[] \|null`|yes|References header values (Message-IDs of the email thread).| |`email.parsed.attachments`|`WebhookAttachment[]`|yes|List of attachments with metadata. May contain partial attachment metadata even when parsing failed.| |`email.parsed.attachments[].filename`|`string \|null`|yes|Original filename from the email. May be null if the attachment had no filename specified.| |`email.parsed.attachments[].content_type`|`string`|yes|MIME content type, for example `"application/pdf"` or `"image/png"`.| |`email.parsed.attachments[].size_bytes`|`integer`|yes|Size of the attachment in bytes.| |`email.parsed.attachments[].sha256`|`string`|yes|SHA-256 hash of the attachment content (hex-encoded). Use this to verify attachment integrity after download.| |`email.parsed.attachments[].part_index`|`integer`|yes|Zero-based index of this part in the MIME structure.| |`email.parsed.attachments[].tar_path`|`string`|yes|Path to this attachment within the downloaded tar.gz archive.| |`email.parsed.attachments_download_url`|`string \|null`|yes|URL to download all attachments as a tar.gz archive. Null if the email had no attachments or parsing failed.| ### Auth |Field|Type|Required|Description| |--|--|--:|--| |`email.auth`|`EmailAuth`|yes|Email authentication results for SPF, DKIM, and DMARC.| |`email.auth.spf`|`SpfResult`|yes|SPF verification result.| |`email.auth.dmarc`|`DmarcResult`|yes|DMARC verification result.| |`email.auth.dmarcPolicy`|`DmarcPolicy`|yes|DMARC policy from the sender's DNS record.| |`email.auth.dmarcFromDomain`|`string \|null`|yes|The organizational domain used for DMARC lookups.| |`email.auth.dmarcSpfAligned`|`boolean`|yes|Whether SPF aligned with the From domain for DMARC purposes.| |`email.auth.dmarcDkimAligned`|`boolean`|yes|Whether DKIM aligned with the From domain for DMARC purposes.| |`email.auth.dmarcSpfStrict`|`boolean \|null`|yes|Whether DMARC SPF alignment mode is strict.| |`email.auth.dmarcDkimStrict`|`boolean \|null`|yes|Whether DMARC DKIM alignment mode is strict.| |`email.auth.dkimSignatures`|`DkimSignature[]`|yes|All DKIM signatures found in the email with their verification results.| |`email.auth.dkimSignatures[].domain`|`string`|yes|The domain that signed this DKIM signature (d= tag). This may differ from the From domain.| |`email.auth.dkimSignatures[].selector`|`string \|null`|yes|The DKIM selector used to locate the public key (s= tag).| |`email.auth.dkimSignatures[].result`|`DkimResult`|yes|Verification result for this specific signature.| |`email.auth.dkimSignatures[].aligned`|`boolean`|yes|Whether this signature's domain aligns with the From domain for DMARC.| |`email.auth.dkimSignatures[].keyBits`|`integer \|null`|yes|Key size in bits. Null if the key size could not be determined.| |`email.auth.dkimSignatures[].algo`|`string \|null`|yes|Signing algorithm, for example `"rsa-sha256"` or `"ed25519-sha256"`.| ### Analysis |Field|Type|Required|Description| |--|--|--:|--| |`email.analysis`|`EmailAnalysis`|yes|Email analysis and classification results. Fields may be absent when that analysis was not performed.| |`email.analysis.spamassassin`|`object`|no|SpamAssassin analysis results. Present when spam scoring ran.| |`email.analysis.spamassassin.score`|`number`|yes|Overall spam score. Higher scores indicate higher likelihood of spam.| |`email.analysis.sender`|`object`|no|Primitive managed sender authentication verdict derived from `email.auth`.| |`email.analysis.sender.authenticated`|`boolean`|yes|True when the From domain proved control of the send through aligned SPF, aligned DKIM, or provider attestation.| |`email.analysis.sender.basis`|`"dmarc_aligned" \|"google_provider_attested" \|"spf_aligned" \|"unauthenticated"`|yes|The public basis for the sender authentication verdict.| |`email.analysis.sender.reasons`|`string[]`|yes|Short factual reasons for the sender authentication verdict.| |`email.analysis.bounce`|`BounceAnalysis`|no|Classified delivery failure (DSN) details. Present on `email.bounced` events when the bounce parsed.| |`email.analysis.bounce.sent_email_id`|`string \|null`|yes|The id of your send this bounce belongs to (the same id as `GET /v1/sent-emails/{id}`), or `null` when it could not be linked. Linking is best effort: a bounce links only when it quotes the original Message-ID of a send from your organization and one of its failed recipients was on that send. Bounces of mail you sent through another provider are never linked.| |`email.analysis.bounce.failed_recipient`|`string \|null`|yes|The first failed recipient, as the bounce wrote it. The status and diagnostic fields describe this recipient.| |`email.analysis.bounce.failed_recipients`|`string[]`|yes|Every address the bounce reports as failed, lowercased and deduplicated, up to 100. Includes both the final and the original recipient when an alias or forward rewrote the address. Empty when the bounce names no usable address.| |`email.analysis.tls_report`|`TlsReportAnalysis`|no|Parsed SMTP TLS report (RFC 8460). Present on `email.tls_report` events when the report parsed.| |`email.analysis.dmarc_report`|`DmarcReportAnalysis`|no|Parsed DMARC aggregate report (RFC 7489). Present on `email.dmarc_report` events when the report parsed.| |`email.analysis.forward`|`ForwardAnalysis`|no|Forward detection and analysis results. In managed Primitive webhook deliveries, this object is included only when forwarded content is detected.| |`email.analysis.forward.detected`|`boolean`|yes|Whether any forwards were detected in the email. In managed Primitive webhook deliveries, this is true when the object is present.| |`email.analysis.forward.results`|`ForwardResult[]`|yes|Analysis results for each detected forward.| |`email.analysis.forward.results[].type`|`"inline" \|"attachment"`|yes|Whether the detected forward was inline or in an attachment.| |`email.analysis.forward.results[].attachment_tar_path`|`string`|when attachment|Path to the attachment in the attachments tar archive.| |`email.analysis.forward.results[].attachment_filename`|`string \|null`|when attachment|Original filename of the attachment, if available.| |`email.analysis.forward.results[].analyzed`|`boolean`|when attachment|Whether this attachment was analyzed.| |`email.analysis.forward.results[].original_sender`|`ForwardOriginalSender \|null`|yes|Original sender of the forwarded email, if extractable.| |`email.analysis.forward.results[].original_sender.email`|`string`|yes|Email address of the original sender.| |`email.analysis.forward.results[].original_sender.domain`|`string`|yes|Domain of the original sender.| |`email.analysis.forward.results[].verification`|`ForwardVerification \|null`|yes|Verification result for the forwarded email. Null when an attachment forward was not analyzed.| |`email.analysis.forward.results[].verification.verdict`|`ForwardVerdict`|yes|Overall verdict on whether the forward is authentic.| |`email.analysis.forward.results[].verification.confidence`|`AuthConfidence`|yes|Confidence level for this verdict.| |`email.analysis.forward.results[].verification.dkim_verified`|`boolean`|yes|Whether a valid DKIM signature was found that verifies the original sender.| |`email.analysis.forward.results[].verification.dkim_domain`|`string \|null`|yes|Domain of the DKIM signature that verified the forward, if any.| |`email.analysis.forward.results[].verification.dmarc_policy`|`DmarcPolicy`|yes|DMARC policy of the original sender's domain.| |`email.analysis.forward.results[].summary`|`string`|yes|Human-readable summary of the forward analysis.| |`email.analysis.forward.attachments_found`|`integer`|yes|Total number of .eml attachments found.| |`email.analysis.forward.attachments_analyzed`|`integer`|yes|Number of .eml attachments that were analyzed.| |`email.analysis.forward.attachments_limit`|`integer \|null`|yes|Maximum number of attachments that will be analyzed, or null if unlimited.| ## Sent Email Events Primitive can also tell you what happened to mail you send: through the API, from a Primitive Function, or from Google Workspace through a [mail relay](/docs/mail-relay). ### Opting in These events are **opt-in**: an endpoint receives them only if its `rules.event_types` lists them by name. An endpoint that leaves `event_types` unset never receives them, so handlers written for the inbound payload are unaffected. An endpoint with a list receives exactly what it lists, so if you add `sent_email.*` names to an endpoint that also handles inbound mail, keep `email.received` (and any other inbound events you handle) in the list. We recommend a dedicated endpoint for them: a handler written for `email.received` that receives a different payload may fail or reject it. Whatever the endpoint, route on the `event` field (also sent as the `X-Webhook-Event` header) and return 2xx for event types you do not handle. Which endpoints receive them: http endpoints that list the event, are not route targets, are scoped to the sending domain if they are domain-scoped, and listed a `sent_email.*` event before the send was made. Turning on a subscription does not replay earlier sends. Pull endpoints and Function endpoints do not receive `sent_email.*` events, and an endpoint's sender and size rules do not apply to them. ### Events |Event|When it fires| |--|--| |`sent_email.accepted`|Exactly once for every send Primitive accepted, whatever state the send has reached by the time the event is created. Its `sent_email.status` can already be `delivered`.| |`sent_email.delivered`|A recipient's receiving server accepted the message.| |`sent_email.failed`|A recipient permanently could not be delivered to: the receiving server rejected the message, or kept asking to retry until the retry period ran out.| |`sent_email.completed`|Once per send, when every recipient has a final result, after that send's last `delivered` or `failed` event. Carries the totals.| Every recipient (To, Cc and Bcc) gets exactly one final result: one `delivered` or one `failed`, never both, never a second one. The one exception is a mail relay send to colleagues in your own Workspace: they are delivered by your mailbox provider, not through the relay, so Primitive has no result for them, sends no event for them, and lists them in `sent_email.completed`'s `not_relayed` (see [Not relayed](#not-relayed)). There is no event for a temporary deferral: the receiving server asked Primitive to retry, and only the final result produces an event. Retries continue for up to 5 days, so a result can arrive days after the send. ### Sends with no result events * A send that ends `gate_denied` or `agent_failed` returned an error to your API call and produces no events. * A scheduled send canceled before its send time produces no events. * A send that ends `unknown` after `sent_email.accepted` produces no result events and no `sent_email.completed`. * Results for a send can arrive later than usual when Primitive has to reconcile them. ### Example ```json { "id": "6f0f8a1e-5c2d-4b8e-9a7d-3c1e2f4a5b6c", "event": "sent_email.failed", "version": "2026-12-18", "created_at": "2026-12-18T10:00:04.000Z", "delivery": { "endpoint_id": "8c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "attempt": 1, "attempted_at": "2026-12-18T10:00:05.000Z" }, "sent_email": { "id": "0b1c2d3e-4f50-4172-8394-a5b6c7d8e9f0", "status": "deferred", "status_changed_at": "2026-12-18T10:00:03.000Z", "created_at": "2026-12-18T10:00:00.000Z", "source": "api", "from_address": "agent@example.com", "to_addresses": ["alice@example.org"], "cc": ["bob@example.net"], "bcc": ["audit@example.com"], "recipient_count": 3, "subject": "Your quote", "message_id": "", "thread_id": "5d1f7a3c-2b9e-4c6d-8a0f-1e2d3c4b5a69", "tags": [{ "name": "campaign", "value": "q4-renewals" }], "relay": null }, "scope": "recipient", "recipient": { "address": "bob@example.net", "type": "cc" }, "outcome": { "result": "failed", "failure_kind": "rejected", "smtp_response_code": 550, "smtp_enhanced_status_code": "5.1.1", "smtp_response_text": "550 5.1.1 : Recipient address rejected: User unknown", "at": "2026-12-18T10:00:04.000Z" } } ``` The `sent_email.completed` for the same send, once Alice and the audit copy are delivered: ```json { "id": "2a7e9c4d-1b3f-4e5a-8c6d-7f8e9a0b1c2d", "event": "sent_email.completed", "version": "2026-12-18", "created_at": "2026-12-18T10:00:09.000Z", "delivery": { "endpoint_id": "8c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f", "attempt": 1, "attempted_at": "2026-12-18T10:01:02.000Z" }, "sent_email": { "id": "0b1c2d3e-4f50-4172-8394-a5b6c7d8e9f0", "status": "delivered", "recipient_count": 3, "...": "the same fields as above" }, "summary": { "recipient_count": 3, "delivered": 2, "failed": 1, "failed_by_kind": { "rejected": 1, "expired": 0 }, "not_relayed": 0, "not_relayed_recipients": [] } } ``` ### Top-level fields * `id`: the event id, an opaque string. It is the same on every retry and at every endpoint, and equal to the `X-Webhook-Id` header. * `event`: the event type, also sent as the `X-Webhook-Event` header. * `version`: the version of the `sent_email.*` payload family, versioned separately from the inbound `email.*` payload. It is not an event date. * `created_at`: when the event happened, as far as Primitive knows it: the result's time for `delivered` and `failed`, the last result's time for `completed`. For `sent_email.accepted` it is the acceptance time only if the event was created while the send was still in its accepted state; otherwise it is when the event was created, which can be later than a result for the same send. * `delivery`: this webhook attempt, not the email's delivery: `endpoint_id`, `attempt` (1 for the first attempt) and `attempted_at`. * `sent_email`: the send (below). * On `delivered` and `failed`: `scope`, `recipient`, `outcome`, and on roll-ups `recipient_count`, `reason` and (failed roll-ups) `recipients`. * On `completed`: `summary` (`recipient_count`, `delivered`, `failed`, `failed_by_kind`, `not_relayed`, `not_relayed_recipients`). Keys that do not apply to an event are absent, not null: `sent_email.accepted` carries none of `scope`, `recipient`, `recipient_count`, `reason`, `outcome`, `recipients` or `summary`, and a `scope: "recipient"` event carries no `recipient_count`, `reason` or `recipients`. ### What `sent_email` is The send's **current** state at the moment this delivery is attempted, with the same fields as [`GET /v1/sent-emails/{id}`](/docs/api). It is not a snapshot of when the event was created, so two attempts of the same event can carry different `sent_email` values, and it is not this event's result: use `outcome` and `recipient` for that. To keep a send's latest state, keep the `sent_email` from the attempt with the latest `delivery.attempted_at`. * `id`: the id the send API returned. For mail relay sends there is no API call, and the id first appears in these events. * `status`: the send's overall status as `GET /v1/sent-emails/{id}` reports it. It can disagree with any single recipient's result: it can read `delivered` while a recipient failed, or `deferred` while another recipient was already delivered. Never use it as a recipient's result, and never use it to decide when every recipient is done: use `sent_email.completed`. Values: `queued` (accepted by the API, not yet handed to the sender), `scheduled` (waiting for its send time), `submitted_to_agent` (handed to Primitive's outbound mail server; nothing to do with AI agents), `deferred` (a receiving server asked to retry later), `delivered`, `bounced`, `wait_timeout` (a send that waited for its result stopped waiting; delivery continues), `unknown` (Primitive could not confirm the result), `agent_failed`, `gate_denied`, `canceled`. Treat it as an open set. * `recipient_count`: every recipient of the send, To, Cc and Bcc together, each address counted once: always the size of `to_addresses`, `cc` and `bcc` combined without duplicates. To know when every recipient is done, use `sent_email.completed`, whose `delivered + failed + not_relayed` equals it. * `source`: how the mail entered Primitive, `api` or `mail_relay` (Function sends report `api`). It says nothing about who wrote it. Treat unknown values as newer additions. * `relay`: null for API sends. For relay sends, `hostname` (the Primitive relay that took the message) and `via` (`mail_relay`). * `cc` and `bcc` are null when the send had none. `tags` are the ones given on the send request, null or empty when there are none. Relay sends carry no tags; correlate them by `message_id` or `thread_id`. * For the time a send was created, use `sent_email.created_at`. There is no separate acceptance time, and `status_changed_at` is not one. ### Scope: per recipient, or a roll-up `scope: "recipient"` events name one recipient in `recipient` (`{address, type}`). This is how every recipient of every send is reported, up to 100 per send. Sends with more than 100 recipients (only possible for mail relay sends) report the first 100, in the order To, then Cc, then Bcc, with their own events, and the rest in roll-ups: `scope: "message"`, `recipient: null`, `reason: "rollup"`, and `recipient_count`, the number of recipients that roll-up covers. Roll-ups have null `smtp_*` fields in `outcome`. * **Failed roll-ups are sent early.** As failures among the rolled-up recipients become known, Primitive sends a `sent_email.failed` roll-up for each `failure_kind` with new failures. It lists them in `recipients`: one entry per failed recipient with `address`, `type`, `smtp_response_code`, `smtp_enhanced_status_code`, `smtp_response_text` and `at`, and its `recipient_count` is the length of that list. Each failed roll-up covers only the recipients it lists, a send can have several, and no recipient is ever listed twice. * **One delivered roll-up** is sent once every rolled-up recipient has a final result. It carries the count of delivered rolled-up recipients and no addresses. The first 100 recipients' own events, plus the failed roll-ups' lists, plus the delivered roll-up's count, add up to every recipient the relay sent to. Sends recorded before Primitive kept each recipient's result can instead report one result for several recipients: `scope: "message"`, `reason: "legacy_message_result"`, with `recipient_count` saying how many recipients it covers. This applies only to those older sends. ### outcome `outcome` is this event's result: * `result`: `delivered` or `failed`, matching the event name. * `failure_kind`: null when delivered; otherwise `rejected` or `expired` (below). * `smtp_response_code`, `smtp_enhanced_status_code`, `smtp_response_text`: the receiving server's answer. On `expired` they hold its last temporary answer. Null on roll-ups. * `at`: when the result was recorded. ### Failure kinds * `rejected`: the receiving server refused the message permanently. This covers mailboxes that do not exist (enhanced status `5.1.x`) and policy or spam refusals (`5.7.x`). Decide suppression on `smtp_enhanced_status_code`, for example suppress on `5.1.x`, not on `rejected` alone. * `expired`: the receiving server kept asking to retry until the 5-day retry period ran out. Do not suppress on it: the address may be fine. The list is closed for this version. ### Recipient types `to`, `cc` or `bcc`: the list that names the recipient. On a mail relay send, every recipient the message was sent to but that no To or Cc header names is `bcc` and is listed in `sent_email.bcc`, whether it was a Bcc recipient or, for example, a copy added by a routing rule: Gmail removes the Bcc header before relaying, so the two cannot be told apart. Addresses are lowercased, and a recipient named in more than one list is one recipient, typed by the first list that names it (To, then Cc, then Bcc). ### Not relayed On a mail relay send, To and Cc can name colleagues in your own Workspace. Colleagues in the same Workspace are delivered by your mailbox provider, not through the relay, so Primitive has no result for them; they are listed in `not_relayed`. They get no per-recipient events and are never counted as delivered. Primitive knows exactly who the relay received (its SMTP envelope), so everyone else named in To or Cc is not relayed. `sent_email.completed` is sent once every relayed recipient has a final result. Its `summary` carries: * `not_relayed`: how many recipients were not relayed. Always `0` for sends made through the API. * `not_relayed_recipients`: those recipients, each `{address, type}`. `[]` when there are none. `summary.delivered + summary.failed + summary.not_relayed` always equals `summary.recipient_count`. For a relay send recorded before Primitive kept the relay's envelope, who was relayed cannot be known: `not_relayed` and `not_relayed_recipients` are `null`, and every recipient is treated as relayed. ### Delivered is final, and late bounces `sent_email.delivered` means the recipient's server accepted the message, not that it reached the inbox. A recipient's first final result is never followed by another. If that server later sends a bounce message, it arrives as the inbound [`email.bounced`](#email-event-types) event, whose `email.analysis.bounce.sent_email_id` links it to the send. ### Duplicates, retries and timing * The body `id` (equal to the `X-Webhook-Id` header) is the same on every retry and at every endpoint: store it and skip repeats. Never compare bodies to detect duplicates; `delivery` and `sent_email` differ between attempts. If several endpoints share one store, key on `id` alone to process each event once, or on `id` plus `delivery.endpoint_id` to process it once per endpoint. * The first attempt is made within about two minutes of the result. * If your endpoint does not return 2xx within 10 seconds, the event is retried with the same `id` and a higher `delivery.attempt`: about 5 minutes, 30 minutes, 2 hours and 8 hours after each failed attempt, so at most 5 attempts over about 11 hours. Size your dedupe store for at least a day. * Order is best effort. Results for a send are normally held until its `sent_email.accepted` has been delivered, and its `sent_email.completed` until its results have, but not indefinitely, so an event can arrive before an earlier one. Treat `sent_email.accepted` as first for its `sent_email.id` and `sent_email.completed` as last, and order results by `created_at`. ### Signing Sent email events are signed exactly like every other event: see [Signature Verification](/docs/signature-verification). * The header is `Primitive-Signature: t=,v1=.">`. * The key is your organization's webhook secret, used exactly as Primitive shows it: the whole string, including the `whsec_` prefix when it has one. Some secrets have no prefix; use those as shown too. Every endpoint is signed with the same secret, so also check that `delivery.endpoint_id` is the endpoint that received the request. * `t` is recomputed on every attempt. Reject values more than 300 seconds old. * Verify against the raw request bytes, before parsing. Worked example: with the secret `whsec_example`, `t=1797588005` and the raw body `{"id":"9f1c3a52-7d4e-4b8a-9c21-5e6f7a8b9c0d","event":"sent_email.completed"}`, the signed string is `1797588005.` followed by that body, and the header is: ```text Primitive-Signature: t=1797588005,v1=4f93d0ceb7f6fdd4a71f4d6beb08a5624a753a376841fd4aa93c62ecaa42c9b2 ``` An SDK version that does not know these events treats them as unknown events; check the event type before parsing the body as an inbound email. ### Safety and deletion * Every event carries the full recipient list including Bcc, and failed roll-ups list addresses: never forward payloads to recipients. * `smtp_response_text` is text written by a third-party server. It can contain addresses and its length is not fixed: size storage for several kilobytes. Treat it as untrusted and never render it as HTML. * Deleting a sent email stops its events: pending ones are not delivered, and the stored copies of payloads already delivered are cleared. ## x402 Payment And Interaction Events x402 settlements deliver their own events, separate from `email.received`. The event name is in the `X-Webhook-Event` header. Subscribe an endpoint to these names (or accept all events) to receive them. x402 is in an invite-only soft launch; see [Collecting Payments](/docs/collecting-payments) and [x402 over Email](/docs/x402-over-email). ### Payment events `payment.settled` and `payment.failed` fire for BOTH the synthetic API pay flow and the email-native settle flow, with an identical payload shape. For email-native settlements `payer_org` is resolved from the payer's pinned, DKIM-authenticated sending domain; it is `null` only when that domain does not resolve to an on-net org. The payee (the org that issued the challenge and receives the funds) always gets the event, with `direction` set to `received`. When the payer resolves to an on-net org (`payer_org` non-null, the normal case on both flows), that org ALSO gets its own copy of the same outcome, with `direction` set to `sent`. This gives an org that auto-pays a challenge an asynchronous settlement channel of its own, so it can reconcile a payment it made without relying on the synchronous response of the pay call. When the payer is genuinely off-net (`payer_org` null), only the payee's `received` event is emitted. `payment.settled` as seen by the payee (`direction: "received"`): ```json { "type": "payment.settled", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": "22222222-2222-4222-8222-222222222222", "direction": "received", "settle_tx": "0x..." } ``` The same settlement as seen by the on-net payer (`direction: "sent"`): ```json { "type": "payment.settled", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": "22222222-2222-4222-8222-222222222222", "direction": "sent", "settle_tx": "0x..." } ``` `payment.failed` carries `failure_reason` instead of `settle_tx`, and likewise reaches both sides when the payer is on-net: ```json { "type": "payment.failed", "challenge_id": "11111111-1111-4111-8111-111111111111", "network": "base-sepolia", "amount": "10000", "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", "payer_org": null, "direction": "received", "failure_reason": "settle_daily_cap_exceeded" } ``` |Field|Type|Description| |--|--|--| |`type`|`"payment.settled" \|"payment.failed"`|The event type; also the `X-Webhook-Event` header value.| |`challenge_id`|`string`|The challenge that settled or failed.| |`network`|`string`|`base` or `base-sepolia`.| |`amount`|`string`|Amount in token base units (USDC has 6 decimals).| |`asset`|`string`|The token contract (checksummed).| |`payer_org`|`string \|null`|The paying org for synthetic payments, `null` for email-native (off-net payer).| |`direction`|`"received" \|"sent"`|Which side of the payment this delivery is for. `received` is the payee's copy; `sent` is the on-net payer's copy. An off-net payer never receives a `sent` event.| |`settle_tx`|`string`|On-chain settlement tx hash. Present on `payment.settled` only.| |`failure_reason`|`string`|Why settlement failed. Present on `payment.failed` only.| ### Interaction events (email-native) Each accepted step of an email-native x402 interaction also emits a protocol-specific `interaction.x402.*` event: `interaction.x402.settled` for a `receipt` step, `interaction.x402.rejected` for a `reject`, plus `interaction.x402.challenge`, `interaction.x402.payment`, `interaction.x402.declined`, `interaction.x402.expired`, and `interaction.x402.verify_timeout` for the other steps. The payload is a snapshot of the interaction at that step: ```json { "interaction": { "id": "...", "wire_id": "@agent.payee.example", "protocol": "x402.payment", "protocol_version": 1, "state": "completed", "failure_reason": null, "role": "initiator", "awaiting": null, "counterparty_address": "wallet@agent.payer.example", "counterparty_domain": "agent.payer.example", "our_address": "billing@agent.payee.example", "step": "receipt" } } ``` A settled email-native payment therefore delivers both an `interaction.x402.settled` event (the step) and a `payment.settled` event (the uniform settlement notification); a rejected one delivers `interaction.x402.rejected` and `payment.failed`. They complement each other: subscribe to `payment.*` for a uniform settlement signal across both rails, or to `interaction.x402.*` for step-level detail. ## Security Every webhook includes a `Primitive-Signature` header. Verify it before trusting the payload. See [Signature Verification](/docs/signature-verification). # Which Onboarding Fits? URL: https://www.primitive.dev/docs/which-onboarding Decide between the product-side and agent-side onboarding flows at the start of your Primitive account. When you sign up for Primitive, you pick one of two paths: * **My Product Should Chat With Agents** — you have a product and you want agents to be able to reach it over email. * **My Agents Want To Chat With The World** — you are building an agent that needs its own email inbox to send and receive. The infrastructure is the same underneath. The onboarding flows are different because the *thing you are setting up first* is different. Pick the path that matches the task you have in front of you. You can always do the other later from the dashboard. ## My Product Should Chat With Agents Pick this if you own a product and want it to be reachable by any agent over email. You are this user if you say things like: * "I run a SaaS and I want Claude or ChatGPT to be able to check order status by emailing my product." * "I want to expose a few of my product's capabilities (lookups, scheduling, refunds) as actions an agent can take by sending a message." * "I have a CRM / helpdesk / internal tool and I want agents to log activity or fetch data by emailing it." * "I want my product to participate in agent-driven workflows without forcing my customers to integrate an MCP server." The onboarding for this path explains how Functions work with a worked example — an inbound email triggers your code, which can parse it, call your own APIs or an LLM, and send a reply — then hands a prompt to your coding agent (Claude Code, Cursor, or Codex) to scaffold and deploy your first Function. There is nothing to deploy by hand and no key to copy: `PRIMITIVE_API_KEY` is injected into every Function automatically. ## My Agents Want To Chat With The World Pick this if you are the developer of an agent and the agent itself needs an email address. You are this user if you say things like: * "My autonomous agent needs to send transactional or follow-up email on behalf of users." * "I want my agent to have an inbox other people can email it at, with the messages delivered to my code." * "I am wiring email send and receive into a Claude / Cursor / custom-agent loop and I just need a working programmatic surface." * "I want a CLI my agent can shell out to, or an SDK my agent can import, with no infrastructure to deploy." The onboarding for this path provisions an inbox immediately and leads with one-click handoffs to Claude Code, Cursor, and Codex — your coding agent installs the CLI, signs in as you, and wires send + receive into your codebase. A live inbox panel displays incoming mail as it arrives, and a "Do it manually" section has the raw install + login + send commands if you would rather drive it yourself. ## Can I Do Both? Yes. They share the same primitives — a managed domain, an API key, the same send and receive pipeline. Once you finish either onboarding flow, the other is available from the dashboard. The recommended sequence: 1. **Start with whichever maps to your immediate task.** Don't pick the "bigger" one to be safe; the irrelevant one wastes your first session. 2. **Add the second when you actually need it.** A product team often grows into needing their own internal agents; an agent developer often grows into wanting their agent to be reachable by other agents. Both transitions happen later, not on day one. ## Still Not Sure? If you are a developer evaluating the platform with no specific product in mind, take **My Agents Want To Chat With The World**. It is faster (the inbox is live in under a minute), the live inbox panel gives you something tangible to watch as real mail arrives, and the infrastructure you set up here is the same infrastructure the product-side flow uses later. If you are evaluating Primitive on behalf of a business or product team, take **My Product Should Chat With Agents**. The Functions example makes the inbound-email-triggers-your-code model concrete, and the handoff prompt at the end is designed to drop into a coding agent and produce a working first Function without you writing it line by line. If you are not a developer at all, take **My Product Should Chat With Agents** and pass the handoff prompt to your engineering team. [Take me back to onboarding](/onboarding) # x402 over Email URL: https://www.primitive.dev/docs/x402-over-email Charge and pay other agents with x402 over real, DKIM-authenticated email threads. A payee issues a challenge addressed to a specific payer, the payer signs an EIP-3009 authorization bound to that challenge and replies, and the platform verifies DKIM plus the signature and settles on-chain. Email-native x402 carries an [x402](https://www.x402.org/) payment over a real email thread between two agents. A payee issues a challenge to a specific payer address, an email goes out carrying a structured `interaction.json` part, the payer signs an EIP-3009 authorization bound to that challenge and replies, and the platform verifies the reply's DKIM signature plus the payment signature and settles on-chain. The receipt lands back on the same thread. It is the agent-to-agent counterpart to the synthetic x402 flow in [Collecting Payments](/docs/collecting-payments). If you are on the receiving end of a request and just want to pay it, jump to [Pay an x402 Request over Email](/docs/paying-x402-over-email) for the payer-focused walkthrough. Both settle USDC non-custodially through an EIP-3009 authorization the payer signs with their own key, and Primitive never holds the money or the keys. The differences are about identity and transport: * **Identity is the sending email address.** In the synthetic flow, the payer is an organization holding an API key and the challenge id is a `@x402.primitive` placeholder. In the email-native flow, both parties are agents on their own domains or subdomains, and a payment is only trusted when it arrives over a DKIM-authenticated email from the address the challenge was sent to. * **The thread is the real channel.** The challenge, the payment, and the receipt are all steps on one real email conversation, not API objects passed out-of-band. * **Outcomes arrive on the thread and as webhook events.** A settlement receipt or a rejection comes back as a reply on the same thread, and the platform also emits a `payment.settled` / `payment.failed` webhook event (identical in shape to the synthetic flow's, with `payer_org` resolved from the payer's pinned, DKIM-authenticated sending domain, or `null` when that domain is not an on-net org) so a payee can react programmatically without reading the thread. Amounts are always in **token base units**. USDC has 6 decimals, so `"10000"` is `0.01` USDC. Supported networks are `base` (mainnet) and `base-sepolia` (testnet); the asset is that network's USDC, resolved server-side. ## How it differs from synthetic x402 ||Synthetic x402|x402 over email| |--|--|--| |Counterparty identity|Organization holding an API key|The sending email address, proven by DKIM| |Challenge id|`@x402.primitive` placeholder|`@` real email thread| |Transport|Challenge JSON passed however you like|A real email carrying an `interaction.json` part| |Outcome signal|`payment.settled` / `payment.failed` webhook|A `receipt` / `reject` step on the thread, plus the same `payment.settled` / `payment.failed` webhook| |Payer trust|Spend policy on the paying org|DKIM alignment to the pinned payer address| The signing primitive is the same in both: the payer signs an EIP-3009 `TransferWithAuthorization` whose nonce is bound to the specific challenge, so a signature captured for one challenge cannot be replayed against another. ## Prerequisites * An API key for the payee side. See [Quickstart](/docs/quickstart). * A sending address each agent controls. Each agent should be on its own domain or subdomain so DKIM aligns to that agent and not a sibling. * A wallet you control on the chosen network. The private key signs locally and is never sent to Primitive. To **receive**, register a payout address (below); to **pay**, hold USDC in the signing wallet. ## Requesting access x402 is in an invite-only soft launch. The capability is gated by two organization entitlements (`x402_payments` for issuing and settling, `interactions_enabled` for processing inbound payment replies), both granted by the Primitive team and off by default. If the email-challenge endpoint returns `feature_disabled`, the feature is not enabled for your organization yet. To request access, contact [support](https://www.primitive.dev/contact) or email the Primitive help agent at `dev@agent.primitive.dev`. ## The end-to-end flow 1. **Issue.** The payee calls `POST /v1/x402/email-challenges` with `from` (the payee's sending address), `to` (the payer's address), `amount`, `network`, and an optional `expires_in`. The platform resolves the payee's payout wallet server-side, sends a real email from the payee to the payer carrying the `interaction.json` `challenge` step, and returns the challenge details. 2. **Sign and reply.** The payer reads the challenge, signs an EIP-3009 authorization bound to that challenge's nonce, and replies on the thread with a `payment` step carrying the signed payload. 3. **Verify and settle.** The platform verifies that the reply is DKIM-authenticated as the pinned payer, verifies the signed payment against the recorded challenge, and settles on-chain through a facilitator. 4. **Receipt.** A `receipt` step (or a `reject` step on failure) is sent back as a reply on the same thread. The interaction moves through these states: `awaiting_payment` (the payer owes a `payment` or a `decline`), `verifying` (the payee platform owes a `receipt` or a `reject`), and a terminal `$completed`, `$failed`, or `$expired`. ## The pinned-payer model When the payee issues the challenge, it is addressed to one specific payer address in `to`. The payer's domain is **pinned** onto the interaction. From then on, the payment reply is only trusted if it is DKIM-authenticated and the signing domain aligns to the pinned payer domain: an exact match, or a true subdomain of it. This is why each agent should live on its own domain or subdomain. Alignment is directional. If you pin `pay.example.com`, a reply DKIM-signed by `pay.example.com` or `wallet.pay.example.com` aligns, but a reply signed by a sibling like `marketing.example.com` does not. Pinning the payer to its own address means a third party cannot pay (or impersonate the payer on) a challenge that was not addressed to them, even on a shared parent domain. The required DKIM coverage is a passing signature over at least the `From` and `Content-Type` headers. The `interaction.json` part rides inside the signed message, so coverage of those headers is what binds the structured payment step to the authenticated sender. ## Step 1: Register a payout address (payee) Before you can issue a challenge, register the wallet that will receive funds on each network. The `pay_to` in a challenge is always resolved server-side from your registered payout directory, never from challenge input. You prove control of the address by signing an org-bound message locally; the recovered address becomes your default for that network. Payout resolution is address-centric: when a challenge is issued, the platform prefers a payout wallet bound to the exact sending address (`from`), and falls back to your organization's default payout address for that network. Register the default first; that alone is enough to start issuing. **CLI** ```bash export PRIMITIVE_X402_PRIVATE_KEY=0x primitive payments register-payout-address --network base-sepolia ``` The CLI resolves your organization id automatically and prints the registered address. **cURL** The raw API expects an already-signed ownership message (`address`, `network`, `signature`, `issued_at`). Build the signature with one of the SDKs or the CLI rather than by hand: ```bash curl -X POST https://api.primitive.dev/v1/x402/payout-addresses \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "address": "0x...", "network": "base-sepolia", "signature": "0x...", "issued_at": "2026-06-22T12:00:00.000Z" }' ``` **Node.js** ```typescript import { createX402Client } from '@primitivedotdev/sdk/x402'; import { privateKeyToAccount } from 'viem/accounts'; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); const signer = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`); const address = await x402.registerPayoutAddress( { network: 'base-sepolia' }, { signer }, ); console.log(address.address, address.is_default); ``` **Python** ```python import os from primitive.x402 import create_x402_client, PrivateKeySigner client = create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"]) signer = PrivateKeySigner(os.environ["WALLET_PRIVATE_KEY"]) address = client.register_payout_address(signer=signer, network="base-sepolia") print(address.address, address.is_default) ``` **Go** ```go x402 := primitive.NewX402Client(primitive.X402ClientOptions{ APIKey: os.Getenv("PRIMITIVE_API_KEY"), }) signer, err := primitive.NewPrivateKeySigner(os.Getenv("WALLET_PRIVATE_KEY")) if err != nil { log.Fatal(err) } address, err := x402.RegisterPayoutAddress(context.Background(), primitive.X402PayoutRegistrationInput{ Network: "base-sepolia", }, signer) if err != nil { log.Fatal(err) } fmt.Println(address.Address, address.IsDefault) ``` ## Step 2: Issue a challenge over email (payee) Call `POST /v1/x402/email-challenges`. The platform sends a real email from `from` to `to` carrying the challenge, then returns the thread id and the details the payer needs to sign. Ownership of `from` is enforced the same way as a normal send, so accepting it from the request is safe. The payout wallet (`pay_to`) and the asset are never taken from the request: `pay_to` is resolved from your ownership-proven payout directory, and the asset is the network's USDC. |Field|Required|Notes| |--|--|--| |`from`|yes|Payee's sending address (the funds receiver)| |`to`|yes|Payer's address (the counterparty)| |`amount`|yes|Token base units, positive integer string (e.g. `"10000"`)| |`network`|yes|`base` or `base-sepolia`| |`expires_in`|no|Challenge lifetime in seconds; 60 to 86400, defaults to 300| |`resource`|no|URL identifier for what is being paid for| |`description`|no|Human-readable description, up to 512 characters| All three SDKs wrap this as `createEmailChallenge`, and the CLI exposes it as `primitive payments create-email-challenge`. You can also call the endpoint directly. **CLI** ```bash primitive payments create-email-challenge \ --from billing@agent.payee.example \ --to wallet@agent.payer.example \ --amount 10000 \ --network base-sepolia \ --expires-in 600 \ --description "Access fee" ``` **cURL** ```bash curl -X POST https://api.primitive.dev/v1/x402/email-challenges \ -H "Authorization: Bearer $PRIMITIVE_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: invoice-1024" \ -d '{ "from": "billing@agent.payee.example", "to": "wallet@agent.payer.example", "amount": "10000", "network": "base-sepolia", "expires_in": 600, "description": "Access fee" }' ``` **Node.js** ```typescript import { createX402Client } from '@primitivedotdev/sdk/x402'; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); const challenge = await x402.createEmailChallenge({ from: 'billing@agent.payee.example', to: 'wallet@agent.payer.example', amount: '10000', // or amountUsdc: '0.01' network: 'base-sepolia', expiresIn: 600, description: 'Access fee', idempotencyKey: 'invoice-1024', }); // `challenge.interaction_id` is the real email thread id; hand the whole // object to the payer (they call payEmailChallenge with it). ``` **Python** ```python import os from primitive.x402 import create_x402_client client = create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"]) challenge = client.create_email_challenge( from_="billing@agent.payee.example", to="wallet@agent.payer.example", amount="10000", # or amount_usdc="0.01" network="base-sepolia", expires_in=600, description="Access fee", idempotency_key="invoice-1024", ) ``` **Go** ```go x402 := primitive.NewX402Client(primitive.X402ClientOptions{ APIKey: os.Getenv("PRIMITIVE_API_KEY"), }) challenge, err := x402.CreateEmailChallenge(context.Background(), primitive.X402EmailChargeInput{ From: "billing@agent.payee.example", To: "wallet@agent.payer.example", Amount: "10000", // or AmountUsdc: "0.01" Network: "base-sepolia", Description: "Access fee", IdempotencyKey: "invoice-1024", // ExpiresIn is a *int; set it with a pointer to override the server default. }) ``` The `201` response carries the real email thread id and everything the payer needs: ```json { "success": true, "data": { "interaction_id": "@agent.payee.example", "challenge_id": "", "challenge": { "payment_requirements": { "scheme": "exact", "network": "base-sepolia", "maxAmountRequired": "10000", "payTo": "0x", "asset": "0x", "resource": "x402:challenge:", "description": "Access fee", "maxTimeoutSeconds": 600, "extra": { "name": "USDC", "version": "2" } }, "nonce_binding": { "interaction_id": "@agent.payee.example", "challenge_step_id": "", "challenge_nonce": "<64-hex-chars>" }, "expires_at": "2026-06-22T12:10:00.000Z" } } } ``` This endpoint sends a real email and mints a challenge on every call, so a timed-out retry could otherwise dispatch a second email. Pass an `Idempotency-Key` header: a retry with the same key short-circuits to the original challenge with no second send. A concurrent duplicate that loses the race returns `409 conflict`. ### The interaction.json part The challenge email carries a JSON part named `interaction.json` (`application/json`). It identifies the thread and protocol and carries the wire-visible challenge. The envelope keys are **snake\_case**; a camelCase variant is rejected by the parser: ```json { "interaction_version": 1, "interaction_id": "@agent.payee.example", "protocol": "x402.payment", "protocol_version": 1, "step": "challenge", "step_id": "", "prev_step_id": null, "expires_at": "2026-06-22T12:10:00.000Z", "payload": { "payment_requirements": { "...": "..." }, "challenge_nonce": "<64-hex-chars>" } } ``` Only the wire-visible fields travel on the email. Server-side bookkeeping stays in the platform and is never serialized into the part or echoed to the counterparty. ## Step 3: Sign and reply with a payment (payer) The payer signs an EIP-3009 `TransferWithAuthorization` whose nonce is derived from the challenge's `nonce_binding`, then replies on the thread with a `payment` step carrying the signed payload. The signing key never leaves the payer's machine. The nonce is the keccak256 hash of the binding's `interaction_id`, `challenge_step_id`, and `challenge_nonce`. Because the nonce is bound to this specific challenge, the authorization cannot be replayed against any other challenge or interaction. The signed authorization fields are `from` (payer), `to` (the challenge's `payTo`), `value` (the amount), `validAfter`, `validBefore`, and that bound `nonce`, signed over the token's EIP-712 domain. Use the SDK's `payEmailChallenge`. It takes the `X402EmailChallenge` object the payee handed you (the full response of `createEmailChallenge`) plus your signer, derives the bound nonce, signs the EIP-3009 authorization **locally**, and returns the canonical `interaction.json` payment-step bytes. > **`payEmailChallenge` signs; it does not send or settle.** It returns the signed `interaction.json` payment step and does nothing else. You still have to reply on the same email thread, from the pinned payer address, attaching those bytes as an `interaction.json` part (`application/json`). The platform reads the envelope off your reply, re-derives the nonce, and settles. Sending nothing means nothing settles. ```typescript import { createX402Client, type X402EmailChallenge } from '@primitivedotdev/sdk/x402'; import { privateKeyToAccount } from 'viem/accounts'; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); const signer = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`); // `challenge` is the X402EmailChallenge the payee gave you (e.g. the JSON of // their createEmailChallenge response). const challenge: X402EmailChallenge = JSON.parse(receivedChallengeJson); const { json } = await x402.payEmailChallenge(challenge, { signer }); // `json` is the canonical interaction.json payment step. Reply on the SAME // email thread, FROM the pinned payer address (so DKIM aligns), attaching // `json` as an `interaction.json` part with content type application/json. // Use the inbound SDK client to reply in-thread (see the Functions guide). ``` The CLI equivalent signs and prints the payment step to stdout; you attach it to your reply: ```bash export PRIMITIVE_X402_PRIVATE_KEY=0x primitive payments pay-email-step --challenge-file challenge.json > interaction.json ``` The reply carries the signed payment step as a snake\_case `interaction.json` part (`application/json`), with `prev_step_id` pointing at the challenge step it answers: ```json { "interaction_version": 1, "interaction_id": "@agent.payee.example", "protocol": "x402.payment", "protocol_version": 1, "step": "payment", "step_id": "", "prev_step_id": "", "expires_at": null, "payload": { "payment": { "x402Version": 1, "scheme": "exact", "network": "base-sepolia", "payload": { "signature": "0x...", "authorization": { "from": "0x", "to": "0x", "value": "10000", "validAfter": "", "validBefore": "", "nonce": "0x" } } } } } ``` The payment reply must come from the pinned payer address and be DKIM-authenticated as that address. Sending the reply from a different sender, even one you control, will not be trusted (see Troubleshooting). ### The validity window `payEmailChallenge` computes the EIP-3009 `validAfter` / `validBefore` for you, so you do not hand-set them. The signed window must land inside an accepted band or settlement is refused: * **At least 60 seconds of headroom.** `validBefore` must be at least 60 seconds in the future when the platform settles, or the payment is refused for insufficient settlement headroom. The SDK adds a 5-minute settlement margin past the challenge's `expires_at` to clear this comfortably. * **At most 24 hours wide.** The total `validBefore - validAfter` window is capped at 24 hours. A signed authorization stays settleable on-chain until `validBefore` regardless of the email-side state, so an unbounded window is refused as too wide. Both ends of the band reject outside it. Because the SDK derives the window from the challenge's `expires_at`, a reasonable `expires_in` (the default is fine) keeps you inside it automatically. ## Step 4: Verification, settlement, and the receipt When the payment reply arrives, the platform: 1. Confirms the reply is DKIM-authenticated and aligned to the pinned payer. 2. Verifies the signed payment against the recorded challenge (amount, wallet, network, nonce, deadline). 3. Settles on-chain through a facilitator. 4. Replies on the thread with a `receipt` step on success, or a `reject` step on failure. The `receipt` step carries the settlement transaction and advances the interaction to `$completed`: ```json { "step": "receipt", "payload": { "settle_tx": "0x...", "status": "settled" } } ``` ### Knowing the outcome The terminal outcome reaches the payee two ways, which complement rather than replace each other: * **The `receipt` / `reject` step on the thread.** The settlement (or rejection) lands as a reply on the same email conversation. Read the thread (or your inbound Function) for the terminal step. * **A `payment.settled` / `payment.failed` webhook event.** On every settle the platform also emits a `payment.*` webhook event to the payee's subscribed endpoints, identical in shape to the synthetic flow's event. `payer_org` is resolved from the payer's pinned, DKIM-authenticated sending domain and is `null` only when that domain does not resolve to an on-net org; when it resolves, the payer org also gets its own `direction: "sent"` copy. A `reject` likewise pairs with a `payment.failed` event. Subscribe to `payment.settled` / `payment.failed` to react programmatically without parsing the thread. See [Webhook Payload](/docs/webhook-payload) for the event shapes. These fire alongside the protocol-specific `interaction.x402.settled` / `interaction.x402.rejected` interaction events for the same step. ## Declines and rejects There are two distinct failure shapes, and they are different steps from different parties: * **Decline (payer).** The payer can refuse the charge by replying with a `decline` step instead of a `payment`. A decline carries an optional `reason` and moves the interaction from `awaiting_payment` to `$completed`. It is a clean, terminal "no", not an error. * **Reject (platform).** If the platform cannot verify or settle a submitted payment, it replies with a `reject` step carrying a required `reason` and moves the interaction from `verifying` to `$failed`. A reject also emits a `payment.failed` webhook event for the payee. A decline is a payer choice on the thread; a reject is a platform outcome that pairs with a `payment.failed` webhook event. ## Settle limits Settlement is gated by per-payee-org caps, enforced on the receiving (payee) side as the payment settles. They exist so a mis-issued or hostile challenge cannot move outsized money during the soft launch. * **Per-day cap: 25 USDC** per payee org, per UTC calendar day. * **Per-month cap: 250 USDC** per payee org, per UTC calendar month. * **Per-settle ceiling: 10 USDC** on any single settlement, independent of daily and monthly headroom. The daily and monthly caps, and the per-settle ceiling, are all overridable per org by the Primitive team; the values above are the defaults. A settle that would exceed a cap is refused **without any on-chain movement**, the challenge is marked failed, and the payer receives a `reject` (and the payee a `payment.failed` event) with one of these reasons: * `settle_daily_cap_exceeded`: the payee org's 25 USDC daily window is full. * `settle_monthly_cap_exceeded`: the payee org's 250 USDC monthly window is full. * `amount_exceeds_per_settle_ceiling`: this single settle is over the 10 USDC per-settle ceiling. To raise the daily or monthly cap, contact [support](https://www.primitive.dev/contact); the raise path is staff-side. ## Troubleshooting * **`identity_mismatch`.** The payment reply carried a valid DKIM signature, but the signing domain did not align to the pinned payer domain. In other words, the reply was authenticated as the wrong party. Send the payment from the exact address the challenge was addressed to, on its own domain or a true subdomain of the pinned domain. A sibling subdomain of a shared parent does not align. * **No usable trust (`trust_level`).** The payment reply had no passing DKIM signature covering the `From` and `Content-Type` headers, so the sender could not be authenticated at all. Confirm DKIM is published and aligned for the payer's sending domain, and that the reply is signed over those headers. * **Expired challenge (`challenge_expired`).** The payment arrived after `expires_at`, so it can no longer settle on-chain and the platform replies with a `reject`. The in-thread `reject` reason for this case is `challenge_expired`, matching the synthetic flow's API error code. The payee issues a new challenge; use a larger `expires_in` for slower payers. * **Settlement failure.** The on-chain settlement did not complete (most often insufficient USDC in the paying wallet on that network). The platform replies with a `reject` step; fund the wallet and have the payee issue a fresh challenge. * **Verification failure.** The signed payment did not match the recorded challenge. Confirm the payer signed for the exact wallet, network, amount, and `nonce_binding` from the challenge it is replying to. * **`feature_disabled`.** The email-native x402 capability is not enabled for your organization. * **`no_payout_address`.** The payee has no payout wallet bound to the sending address and no organization default for the network. Register one (Step 1). * **Duplicate or redelivered `payment` replies are safe.** Email is delivered at-least-once, so a payment reply that your mail client double-sends (or that a relay redelivers) can arrive more than once. A duplicate reply settles at most once: the platform processes the first arrival and treats any later copy of the same reply as a no-op, so you never get charged twice and never receive a second `receipt` or `reject`. The on-chain guarantee behind this is the challenge-bound EIP-3009 nonce, which a settlement can consume only once; a replayed authorization is rejected on-chain. ## Endpoint reference |Operation|Method and path| |--|--| |Register payout address|`POST /v1/x402/payout-addresses`| |List payout addresses|`GET /v1/x402/payout-addresses`| |Issue email challenge|`POST /v1/x402/email-challenges`| |Get challenge|`GET /v1/x402/challenges/{id}`| |List payments (ledger)|`GET /v1/x402/payments`| For reconciliation, `GET /v1/x402/payments` returns a cursor-paginated, newest-first ledger of every payment your org sent or received — including email-native ones — with an `Accept: text/csv` export (it sets `X-Truncated: true` when the export hits its size ceiling). See [Collecting Payments](/docs/collecting-payments#reconciliation) for the full description. See [Collecting Payments](/docs/collecting-payments) for the synthetic x402 flow, the [API reference](/docs/api) for full request and response schemas, and the [SDKs](/docs/sdks) and [CLI](/docs/cli) pages for installation.