Primitive Payments API

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).

OpenAPI Specification

primitive-payments-api-openapi.yml Raw ↑
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