openapi: 3.1.0
info:
title: Primitive Account Payments API
version: 1.0.0
description: "Primitive is email infrastructure for AI agents. The Primitive API lets you manage domains, emails, webhook endpoints,\nfilters, and account settings programmatically.\n\n## Authentication\n\nMost endpoints require a Bearer token in the `Authorization` header:\n\n```\nAuthorization: Bearer prim_<your_api_key>\nAuthorization: Bearer prim_oat_<oauth_access_token>\n```\n\nAPI keys and OAuth access tokens are org-scoped. Create and manage them in your dashboard\nunder Settings > API Keys. CLI login plus CLI/agent signup endpoints\nexplicitly declare `security: []`; they do not require an API key because\nthey are used to create OAuth CLI sessions.\n\n## Rate Limiting\n\nThe API enforces a sliding window rate limit of **120 requests per\n60 seconds** per organization. When exceeded, the API returns `429`\nwith a `Retry-After` header indicating how many seconds to wait.\n\n## Pagination\n\nList endpoints use cursor-based pagination. Responses include a\n`meta` object with `total`, `limit`, and `cursor` fields. Pass the\n`cursor` value as a query parameter to fetch the next page. When\n`cursor` is `null`, there are no more results.\n\n## Response Format\n\nAll responses use a consistent envelope:\n\n```json\n{\n \"success\": true,\n \"data\": { ... },\n \"meta\": { \"total\": 42, \"limit\": 50, \"cursor\": \"...\" }\n}\n```\n\nErrors follow the same pattern:\n\n```json\n{\n \"success\": false,\n \"error\": { \"code\": \"not_found\", \"message\": \"Email not found\" }\n}\n```\n\n## Webhook signing\n\nOutbound webhook deliveries (configured via the `endpoints` API)\nare signed so receivers can verify they came from Primitive and\nhave not been tampered with in transit. The signing scheme is\ndeliberately simple so it can be reimplemented in any language\nin a few lines. The Node SDK's `verifyWebhookSignature` helper\nis the reference implementation; the wire details below let you\nwrite a verifier in Python, Go, Ruby, etc. without reading our\nsource.\n\n**Header**: `Primitive-Signature: t=<unix-seconds>,v1=<hex>`\n\nA legacy `MyMX-Signature` header is also sent on every delivery\nwith the same value, retained for back-compatibility with\nintegrations written before the rename. New code should read\n`Primitive-Signature`.\n\n**Signed string**: `${timestamp}.${rawBody}` where `timestamp`\nis the Unix-seconds integer from the `t=` parameter and\n`rawBody` is the exact bytes of the HTTP request body BEFORE\nany JSON decoding. Verify against the raw body, not a\nre-serialized parse, or you will silently mismatch on\ninsignificant whitespace.\n\n**Signature**: HMAC-SHA256 of the signed string, hex-encoded\n(lowercase). Use the account's webhook secret as the HMAC key,\nas a UTF-8 byte sequence.\n\n**Secret**: returned by `GET /account/webhook-secret`. The\nstring looks base64-shaped (e.g. `XNHBBW8VqoBjRfNs1tkZj11jTk...`)\nbut is NOT base64; use it AS-IS as a UTF-8 string for the HMAC\nkey. Base64-decoding before HMAC will silently produce\nmismatched signatures.\n\n**Tolerance**: by convention, reject deliveries whose `t=`\ntimestamp is more than 5 minutes off your wall-clock to defend\nagainst replay attacks. The Node SDK's helper enforces this by\ndefault.\n\n**Verification recipe** (any language):\n\n```\n1. Read the raw HTTP body (do not parse).\n2. Read `Primitive-Signature: t=<ts>,v1=<sig>`.\n3. Reject if abs(now - ts) > 300 seconds.\n4. expected = HMAC_SHA256_hex(secret_utf8, f\"{ts}.{rawBody}\")\n5. Constant-time compare expected to sig. Reject if not equal.\n```\n\nFor Node, use `verifyWebhookSignature` from\n`@primitivedotdev/sdk/webhook` (or the higher-level\n`handleWebhook` helper if you want a one-liner). For other\nlanguages, the recipe above is everything you need.\n\nTest deliveries: `POST /endpoints/{id}/test` triggers a fake\ndelivery to your endpoint URL, signed with your real account\nsecret, so you can confirm verification end-to-end without\nneeding real inbound mail. The test response carries the exact\n`signature` header value sent on the wire so you can compare\nstrings directly.\n\n\n## Errors\n\nEvery error response is the same JSON envelope (`{ \"success\": false, \"error\": { \"code\", \"message\" } }`), served as `application/json` with HTTP status codes, following the RFC 7807 problem-details shape. The `error.code` is a stable machine-readable string and `error.message` is human-readable.\n\n## Authorization and roles\n\nAccess is governed by organization role-based access control. Every organization member holds one of three roles — `owner`, `admin`, or `member` — and a credential inherits a role. **API keys** always act at `member` level, regardless of the role of the user who created them, so an API key can never perform owner- or admin-only actions. **OAuth access tokens** act with the authorizing user's current organization role, resolved on each request. Every operation in this spec is part of the member-level surface, so any valid credential can call it. Organization administration that is not part of this API — billing and organization settings — requires an `owner` or `admin` and is performed in the dashboard. Fine-grained per-key scopes (e.g. a send-only or read-only key) are on the roadmap; today the role model is the unit of access control.\n\n## Versioning\n\nThe current stable API is **v1**. All endpoints are served under `/v1/` and are covered by a backward-compatibility guarantee: existing fields and status codes will not change without a deprecation notice.\n\nBreaking changes are announced at least 6 months in advance via changelog and email. Deprecated operations and fields are marked `x-deprecated: true` in the spec and carry a plain-English description of the replacement. The `v1` path prefix is guaranteed stable indefinitely; backward-compatible additions (new optional fields, new endpoints) may be made at any time without a version bump."
contact:
name: Primitive
url: https://primitive.dev
license:
name: Proprietary
url: https://primitive.dev/terms
x-stability-level: stable
x-deprecation-policy: 'Breaking changes are announced at least 6 months in advance. Deprecated fields carry x-deprecated: true. The current stable version is v1.'
servers:
- url: https://api.primitive.dev/v1
description: Canonical API host (PRIMITIVE_API_BASE_URL). Carries every public API operation.
tags:
- name: Payments
description: 'Collect and pay stablecoin (USDC) payments with x402. Settlement is
non-custodial: funds move directly from payer to payee on-chain via an
EIP-3009 authorization the payer signs with their own key, and Primitive
never holds funds. The payee registers a payout address and creates a
challenge; the payer signs and settles it under a configurable spend
policy (kill-switch, per-payment and per-day caps, payee allowlist).
'
paths:
/x402/payout-addresses:
post:
operationId: registerPayoutAddress
summary: Register a payout address
description: 'Register (or update) the default payout address your org receives x402
payments at, for a given network. You prove control of the address with
an org-bound `personal_sign` signature over the message produced by the
SDK helper `buildPayoutRegistrationMessage`. The org id is taken from your
authenticated key, never the body, so a captured signature can''t register
an address under another org. Exactly one default address exists per
(org, network); registering again replaces it. A payee MUST register a
payout address before calling `createChallenge`, because the challenge''s
`pay_to` is resolved from this directory.
'
tags:
- Payments
security:
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
address:
type: string
pattern: ^0x[0-9a-fA-F]{40}$
description: The payout address (your signer's own EVM address), 0x-prefixed.
network:
type: string
enum:
- base
- base-sepolia
description: The chain the address receives on.
signature:
type: string
pattern: ^0x[0-9a-fA-F]+$
description: 'A `personal_sign` signature over the org-bound message produced by
the SDK helper `buildPayoutRegistrationMessage`. Recovered and
checked against `address`; the org id is bound into the signed bytes.
'
issued_at:
type: string
format: date-time
description: 'ISO-8601 timestamp embedded in the signed message. Must be within a
short freshness window (about 10 minutes) of server time.
'
label:
type: string
maxLength: 80
description: Optional human-readable label.
required:
- address
- network
- signature
- issued_at
responses:
'201':
description: Payout address registered (or updated) and set as default
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
required:
- success
- data
- type: object
properties:
data:
type: object
properties:
id:
type: string
format: uuid
address:
type: string
description: The checksummed payout address.
network:
type: string
enum:
- base
- base-sepolia
label:
type:
- string
- 'null'
is_default:
type: boolean
description: Exactly one address per (org, network) is the default.
verified_at:
type: string
format: date-time
description: When ownership of the address was last proven.
created_at:
type: string
format: date-time
required:
- id
- address
- network
- label
- is_default
- verified_at
'400':
$ref: '#/components/responses/ValidationError'
description: Invalid request parameters
'401':
$ref: '#/components/responses/Unauthorized'
description: Invalid or missing API key
'403':
$ref: '#/components/responses/Forbidden'
description: Authenticated caller lacks permission for the operation
'422':
$ref: '#/components/responses/UnprocessableEntity'
description: 'The request was well-formed but could not be processed. For Payments
this covers a missing payout address, a failed payment verification, a
spend-policy decline, or an expired challenge; `error.code` distinguishes
them.
'
'429':
$ref: '#/components/responses/RateLimited'
description: Rate limit exceeded
parameters:
- name: Idempotency-Key
in: header
required: false
description: Optional client-supplied idempotency key. Retrying a request with the same key returns the original result instead of performing the action a second time; if omitted the server derives one from the canonical payload hash. Safe to retry network failures without duplicating side effects.
schema:
type: string
minLength: 1
maxLength: 255
get:
operationId: listPayoutAddresses
summary: List payout addresses
description: List your org's registered payout addresses, newest first.
tags:
- Payments
security:
- BearerAuth: []
responses:
'200':
description: Your registered payout addresses
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
required:
- success
- data
- type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
format: uuid
address:
type: string
description: The checksummed payout address.
network:
type: string
enum:
- base
- base-sepolia
label:
type:
- string
- 'null'
is_default:
type: boolean
description: Exactly one address per (org, network) is the default.
verified_at:
type: string
format: date-time
description: When ownership of the address was last proven.
created_at:
type: string
format: date-time
required:
- id
- address
- network
- label
- is_default
- verified_at
headers:
ratelimit-limit:
description: Maximum number of requests allowed in the current window.
schema:
type: integer
minimum: 1
example: 120
ratelimit-remaining:
description: Remaining requests in the current window.
schema:
type: integer
minimum: 0
example: 118
ratelimit-reset:
description: Unix timestamp (seconds) when the current window resets.
schema:
type: integer
example: 1700000060
ratelimit-policy:
description: Rate-limit policy in `limit;w=seconds` format, e.g. `120;w=60`.
schema:
type: string
example: 120;w=60
'401':
$ref: '#/components/responses/Unauthorized'
description: Invalid or missing API key
'403':
$ref: '#/components/responses/Forbidden'
description: Authenticated caller lacks permission for the operation
'429':
$ref: '#/components/responses/RateLimited'
description: Rate limit exceeded
/x402/email-challenges:
post:
operationId: createEmailChallenge
summary: Create an email-native payment challenge
description: 'Issue an x402 payment challenge over a real email thread (the payee
side). Unlike `createChallenge` (which mints a synthetic challenge id),
this sends the challenge as an email from `from` to `to` and binds the
payment to that DKIM-authenticated thread. The `pay_to` address and the
token asset are resolved server-side from your registered default payout
address for the network, never from the request. The response carries
the thread''s `interaction_id` plus the `challenge` (the
`payment_requirements`, the `nonce_binding`, and `expires_at`) the payer
needs to sign; the payer replies with a signed `payment` interaction
step. Amounts are in token base units (USDC has 6 decimals, so `"10000"`
is 0.01 USDC).
'
tags:
- Payments
security:
- BearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
description: Optional client-supplied idempotency key. Retrying a request with the same key returns the original result instead of performing the action a second time; if omitted the server derives one from the canonical payload hash. Safe to retry network failures without duplicating side effects.
schema:
type: string
minLength: 1
maxLength: 255
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: false
description: 'Issue a payment challenge over an email thread. `from` is your sending
address (the funds receiver; ownership is enforced at send, exactly as
for outbound mail) and `to` is the payer''s address. The `pay_to` payout
wallet and the token asset are resolved server-side, never taken from
the request.
'
properties:
from:
type: string
format: email
description: 'Your sending address (the payee / funds receiver). Must be an
address your org is allowed to send from.
'
to:
type: string
format: email
description: The payer's email address the challenge is sent to.
amount:
type: string
pattern: ^[1-9][0-9]{0,38}$
description: 'Amount to collect, in token base units (unlike the `charge` CLI
command, which also accepts `--amount-usdc`, this field takes base
units only). USDC has 6 decimals, so `"10000"` is 0.01 USDC:
multiply a human USDC amount by 1,000,000 (0.01 USDC -> `"10000"`).
'
network:
type: string
enum:
- base
- base-sepolia
expires_in:
type: integer
minimum: 60
maximum: 86400
description: Seconds until the challenge expires. Defaults to 300.
resource:
type: string
format: uri
maxLength: 2048
description: Optional URL identifying what is being paid for.
description:
type: string
maxLength: 512
description: Optional human-readable description of the payment.
required:
- from
- to
- amount
- network
responses:
'200':
description: 'Idempotent replay: a request with a previously-used idempotency key
returns the original issued challenge without sending a second email.
'
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
required:
- success
- data
- type: object
properties:
data:
type: object
description: 'The result of issuing an email-native payment challenge. `interaction_id`
is the real email thread id (`uuid@domain`) the payment is bound to;
`challenge_id` is the underlying challenge record. Hand the `challenge`
to the payer, who replies with a signed `payment` interaction step (the
SDK `payEmailChallenge` helper builds it).
'
properties:
interaction_id:
type: string
description: The email thread id (`uuid@domain`) the payment is bound to.
challenge_id:
type: string
format: uuid
description: The underlying challenge record id.
challenge:
type: object
description: 'The challenge the payer needs to sign and pay, carried inside an
email-native challenge response.
'
properties:
payment_requirements:
type: object
description: 'The x402 `PaymentRequirements` the payer signs over. Field names are
x402''s native camelCase, preserved byte-for-byte.
'
properties:
scheme:
type: string
description: The x402 settlement scheme. Always `exact` for v1.
example: exact
network:
type: string
enum:
- base
- base-sepolia
maxAmountRequired:
type: string
description: Amount in token base units.
payTo:
type: string
description: The payee's resolved payout address (checksummed).
asset:
type: string
description: The token contract address (checksummed). USDC.
resource:
type: string
description:
type: string
maxTimeoutSeconds:
type: integer
extra:
type: object
description: 'The token''s load-bearing EIP-712 domain params. `name` differs by
chain (Base mainnet USDC is `USD Coin`, Base Sepolia is `USDC`); a
wrong value produces a signature the verifier rejects.
'
properties:
name:
type: string
version:
type: string
required:
- name
- version
required:
- scheme
- network
- maxAmountRequired
- payTo
- asset
- extra
nonce_binding:
type: object
description: 'The interaction binding the payer hashes into the EIP-3009 nonce
(`deriveEip3009Nonce`). Pinning the nonce to this binding is what lets an
x402 payment ride asynchronous transports safely: a replayed challenge
can''t redirect funds and a signed payment can''t settle twice.
'
properties:
interaction_id:
type: string
description: Interaction id, including its `@domain` part.
challenge_step_id:
type: string
format: uuid
challenge_nonce:
type: string
pattern: ^[0-9a-f]{64}$
description: 32 random bytes as 64 lowercase hex chars.
required:
- interaction_id
- challenge_step_id
- challenge_nonce
expires_at:
type: string
format: date-time
description: ISO-8601 expiry of the challenge.
required:
- payment_requirements
- nonce_binding
- expires_at
required:
- interaction_id
- challenge_id
- challenge
headers:
ratelimit-limit:
description: Maximum number of requests allowed in the current window.
schema:
type: integer
minimum: 1
example: 120
ratelimit-remaining:
description: Remaining requests in the current window.
schema:
type: integer
minimum: 0
example: 118
ratelimit-reset:
description: Unix timestamp (seconds) when the current window resets.
schema:
type: integer
example: 1700000060
ratelimit-policy:
description: Rate-limit policy in `limit;w=seconds` format, e.g. `120;w=60`.
schema:
type: string
example: 120;w=60
'201':
description: Email challenge issued
content:
application/json:
schema:
allOf:
- type: object
properties:
success:
type: boolean
const: true
required:
- success
- data
- type: object
properties:
data:
type: object
description: 'The result of issuing an email-native payment challenge. `interaction_id`
is the real email thread id (`uuid@domain`) the payment is bound to;
`challenge_id` is the underlying challenge record. Hand the `challenge`
to the payer, who replies with a signed `payment` interaction step (the
SDK `payEmailChallenge` helper builds it).
'
properties:
interaction_id:
type: string
description: The email thread id (`uuid@domain`) the payment is bound to.
challenge_id:
type: string
format: uuid
description: The underlying challenge record id.
challenge:
type: object
description: 'The challenge the payer needs to sign and pay, carried inside an
email-native challenge response.
'
properties:
payment_requirements:
type: object
description: 'The x402 `PaymentRequirements` the payer signs over. Field names are
x402''s native camelCase, preserved byte-for-byte.
'
properties:
scheme:
type: string
description: The x402 settlement scheme. Always `exact` for v1.
example: exact
network:
type: string
enum:
- base
- base-sepolia
maxAmountRequired:
type: string
description: Amount in token base units.
payTo:
type: string
description: The payee's resolved payout address (checksummed).
asset:
type: string
description: The token contract address (checksummed). USDC.
resource:
type: string
description:
type: string
maxTimeoutSeconds:
type: integer
extra:
type: object
description: 'The token''s load-bearing EIP-712 domain params. `name` differs by
chain (Base mainnet USDC is `USD Coin`, Base Sepolia is `USDC`); a
wrong value produces a signature the verifier rejects.
'
properties:
name:
type: string
version:
type: string
required:
- name
- version
required:
- scheme
- network
- maxAmountRequired
- payTo
- asset
- extra
nonce_binding:
type: object
description: 'The interaction binding the payer hashes into the EIP-3009 nonce
(`deriveEip3009Nonce`). Pinning the nonce to this binding is what lets an
x402 payment ride asynchronous transports safely: a replayed challenge
can''t redirect funds and a signed payment can''t settle twice.
'
properties:
interaction_id:
type: string
description: Interaction id, including its `@domain` part.
challenge_step_id:
type: string
format: uuid
challenge_nonce:
# --- truncated at 32 KB (82 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/primitive/refs/heads/main/openapi/primitive-payments-api-openapi.yml