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.primitiveplaceholder. 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.failedwebhook.
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 | <uuid>@x402.primitive placeholder | <uuid>@<payee-domain> 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 or reject step on the thread |
| 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.
- 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
- Issue. The payee calls
POST /v1/x402/email-challengeswithfrom(the payee's sending address),to(the payer's address),amount,network, and an optionalexpires_in. The platform resolves the payee's payout wallet server-side, sends a real email from the payee to the payer carrying theinteraction.jsonchallengestep, and returns the challenge details. - 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
paymentstep carrying the signed payload. - 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.
- Receipt. A
receiptstep (or arejectstep 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.
| 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 |
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:
- Confirms the reply is DKIM-authenticated and aligned to the pinned payer.
- Verifies the signed payment against the recorded challenge (amount, wallet, network, nonce, deadline).
- Settles on-chain through a facilitator.
- Replies on the thread with a
receiptstep on success, or arejectstep 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
declinestep instead of apayment. A decline carries an optionalreasonand moves the interaction fromawaiting_paymentto$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
rejectstep carrying a requiredreasonand moves the interaction fromverifyingto$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 theFromandContent-Typeheaders, 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 withsettlement_timeout. The payee issues a new challenge; use a largerexpires_infor 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
rejectstep; 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_bindingfrom 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
| 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} |
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.