x402 over Email

Email-native x402 carries an x402 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. If you are on the receiving end of a request and just want to pay it, jump to Pay an x402 Request 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 <uuid>@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 null) 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 x402x402 over email
Counterparty identityOrganization holding an API keyThe sending email address, proven by DKIM
Challenge id<uuid>@x402.primitive placeholder<uuid>@<payee-domain> real email thread
TransportChallenge JSON passed however you likeA real email carrying an interaction.json part
Outcome signalpayment.settled / payment.failed webhookA receipt / reject step on the thread, plus the same payment.settled / payment.failed webhook
Payer trustSpend policy on the paying orgDKIM 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.
  • 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 or email the Primitive dev_help agent at dev_help@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.

export PRIMITIVE_X402_PRIVATE_KEY=0x<your-wallet-private-key>


primitive payments register-payout-address --network base-sepolia

The CLI resolves your organization id automatically and prints the registered address.

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

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.

FieldRequiredNotes
fromyesPayee's sending address (the funds receiver)
toyesPayer's address (the counterparty)
amountyesToken base units, positive integer string (e.g. "10000")
networkyesbase or base-sepolia
expires_innoChallenge lifetime in seconds; 60 to 86400, defaults to 300
resourcenoURL identifier for what is being paid for
descriptionnoHuman-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.

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

The 201 response carries the real email thread id and everything the payer needs:

{
  "success": true,
  "data": {
    "interaction_id": "<uuid>@agent.payee.example",
    "challenge_id": "<uuid>",
    "challenge": {
      "payment_requirements": {
        "scheme": "exact",
        "network": "base-sepolia",
        "maxAmountRequired": "10000",
        "payTo": "0x<payee-wallet>",
        "asset": "0x<network-usdc>",
        "resource": "x402:challenge:<challenge-id>",
        "description": "Access fee",
        "maxTimeoutSeconds": 600,
        "extra": { "name": "USDC", "version": "2" }
      },
      "nonce_binding": {
        "interaction_id": "<uuid>@agent.payee.example",
        "challenge_step_id": "<uuid>",
        "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:

{
  "interaction_version": 1,
  "interaction_id": "<uuid>@agent.payee.example",
  "protocol": "x402.payment",
  "protocol_version": 1,
  "step": "challenge",
  "step_id": "<uuid>",
  "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.

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:

export PRIMITIVE_X402_PRIVATE_KEY=0x<payer-wallet-private-key>


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:

{
  "interaction_version": 1,
  "interaction_id": "<uuid>@agent.payee.example",
  "protocol": "x402.payment",
  "protocol_version": 1,
  "step": "payment",
  "step_id": "<fresh-uuid>",
  "prev_step_id": "<challenge-step-id>",
  "expires_at": null,
  "payload": {
    "payment": {
      "x402Version": 1,
      "scheme": "exact",
      "network": "base-sepolia",
      "payload": {
        "signature": "0x...",
        "authorization": {
          "from": "0x<payer-wallet>",
          "to": "0x<payee-wallet>",
          "value": "10000",
          "validAfter": "<unix-seconds>",
          "validBefore": "<unix-seconds>",
          "nonce": "0x<bound-nonce>"
        }
      }
    }
  }
}

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:

{
  "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, with payer_org set to null because the payer is off-net. 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 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 are overridable per org by the Primitive team. The per-settle ceiling is a fixed code-level sanity limit and is not currently overridable. 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; 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 (settlement_timeout). 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 currently settlement_timeout (being renamed to challenge_expired to match 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

OperationMethod and path
Register payout addressPOST /v1/x402/payout-addresses
List payout addressesGET /v1/x402/payout-addresses
Issue email challengePOST /v1/x402/email-challenges
Get challengeGET /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 for the full description.

See Collecting Payments for the synthetic x402 flow, the API reference for full request and response schemas, and the SDKs and CLI pages for installation.