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. 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 as steps on the thread. A settlement receipt or a rejection comes back as a reply on the same thread, not as a payment.settled / payment.failed webhook.

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 or reject step on the thread
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.

This is an experimental capability gated by organization entitlements. If the email-challenge endpoint returns feature_disabled, the feature is not enabled for your organization yet.

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

There is no SDK or CLI wrapper for issuing an email-native challenge yet; call the endpoint directly.

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"
  }'

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:

{
  "wireId": "<uuid>@agent.payee.example",
  "protocol": "x402.payment",
  "protocolVersion": 1,
  "step": "challenge",
  "stepId": "<uuid>",
  "prevStepId": null,
  "expiresAt": "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.

import { signInteractionPayment, usdcForNetwork } from '@primitivedotdev/x402-core';
import { privateKeyToAccount } from 'viem/accounts';


const account = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`);


// `req` and `binding` come from the issuer's email: the `payment_requirements`
// and `nonce_binding` inside the interaction.json `challenge` step.
const req = challenge.payment_requirements;
const binding = challenge.nonce_binding;
const usdc = usdcForNetwork(req.network);
if (!usdc) throw new Error(`unsupported network: ${req.network}`);


const { authorization, signature } = await signInteractionPayment({
  sign: (typedData) => account.signTypedData(typedData),
  payer: account.address,
  // The token EIP-712 domain. name/version are load-bearing: Base mainnet USDC
  // is "USD Coin" v2; Base Sepolia is "USDC" v2.
  domain: {
    name: usdc.name,
    version: usdc.version,
    chainId: usdc.chainId,
    verifyingContract: usdc.address as `0x${string}`,
  },
  payTo: req.payTo,
  amount: BigInt(req.maxAmountRequired),
  nonceBinding: binding,
  validAfter: 0n,
  validBefore: BigInt(Math.floor(Date.parse(challenge.expires_at) / 1000)),
});


// Reply on the SAME email thread with a `payment` step whose payload is the
// signed x402 PaymentPayload built from `authorization` + `signature`. Reply
// from the pinned payer address so DKIM aligns. Use the inbound SDK client to
// reply in-thread (see the Functions guide).

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

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" }
}

There is no payment.settled or payment.failed webhook in the email-native flow. The receipt or reject step on the thread is how a payee learns the outcome. Read the thread (or your inbound Function) for the terminal 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.

Both arrive as steps on the thread, never as webhooks.

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. The payment arrived after expires_at, so settlement fails with settlement_timeout. 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).

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}

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.