Agent side
Handle a payment
Answer a 402 challenge: check it, sign it, retry with the proof, and read your receipt.
A paid resource answers the first call with a challenge. The agent checks it, signs a payment for exactly that amount to exactly that recipient, and repeats the same call with the proof attached. On EVM the payment is an EIP-3009 or Permit2 authorization, on Solana a token transfer, and on NEAR a delegate action.
The exchange
- Call without a proof
HTTP answers
402with the envelope in the body and the base64 x402 v2PaymentRequireddocument in thePAYMENT-REQUIREDheader. MCP answersisError: truewith the envelope instructuredContent; for x402 the v2PaymentRequiredfields sit beside it and in_meta["x402/error"], so off-the-shelf x402 MCP clients can read it.json { "status": "payment-required", "code": "PAYMENT_REQUIRED", "requestId": "…", "resourceId": "market_report", "message": "Payment of 0.01 USDC is required for resource \"market_report\". …", "payment": { "provider": "x402", "version": "2", "amount": "0.01", "currency": "USDC", // destination, network and asset are deprecated, and absent when // the challenge lists several offers: read each one in accepts "destination": "0x…", "network": "eip155:31337", "asset": "0x…", "expiresAt": "…", "accepts": [ /* x402 v2 PaymentRequirements, verbatim */ ], "envelope": { /* x402 v2 PaymentRequired, verbatim */ } } } - Check the challenge before signing
Everything in it was supplied by the server. Confirm the network, the recipient (
payTo), the asset and an upper bound on the amount against your own expectations. After you sign, there is nothing left to check.payment.acceptsholds one entry per offer. With several, the envelope omitsdestination,networkandasset, which are deprecated anyway: pick the offer you will pay and check that entry. Your proof echoes it inaccepted, which is how the gateway selects it.typescript // provider-native entries, so their fields are unknown const accepts = envelope.payment.accepts.find((offer) => offer.network === 'eip155:8453'); if (!accepts) throw new Error('no offer on my network'); // EVM addresses compare case-insensitively; Solana and NEAR addresses do not const same = (value: unknown, expected: string) => String(value).toLowerCase() === expected.toLowerCase(); if (!same(accepts.payTo, EXPECTED_MERCHANT)) throw new Error('unexpected recipient'); if (!same(accepts.asset, USDC_ON_BASE)) throw new Error('unexpected asset'); if (BigInt(String(accepts.amount)) > MAX_UNITS) throw new Error('more than I agreed to pay'); - Sign the proof
Any x402 v2 client can consume
payment.envelopeor thePAYMENT-REQUIREDheader directly. The package's own helper, used by the demo agent and the end-to-end suites, returns the base64 value to send back:typescript import { createPaymentProof } from '@devlab.group/agent-commerce/x402'; const proof = await createPaymentProof({ buyerPrivateKey, // the buyer's own key, never the gateway's accepts, // the offer you checked, verbatim }); // Signing is offline: the chain id comes from accepts.networkcreatePaymentProofsigns EIP-3009 offers only; an offer whoseextra.assetTransferMethodis anything else is refused withINPUT_INVALID. Pay Permit2, Solana and NEAR offers with the x402 SDK's own client for that family. A Permit2 buyer first approves the Permit2 contract for the token once, which costs gas. - Retry the same call with the proof
curl -i https://gateway.example.com/api/resources/premium_report/invoke \ -X POST \ -H 'content-type: application/json' \ -H "PAYMENT-SIGNATURE: $PROOF" \ -d '{}' - Read your receipt
Over HTTP the settlement summary comes back as base64 JSON in the
PAYMENT-RESPONSEheader on success and when the backend fails after anupfrontsettlement. A refused or unconfirmed settlement sendssuccess: falsewith anerrorReason. When the provider reportsamount, it is in the asset's base units:PAYMENT-RESPONSE (decoded)json { "success": true, "transaction": "0x4f2c…9ab1", "network": "eip155:8453", "payer": "0x3C44…93BC", "amount": "10000", "status": "settled", "provider": "x402", "currency": "USDC", "externalReference": "0x4f2c…9ab1" }Over MCP the delivery summary rides in the tool result's
_metaunderagent-commerce/delivery, next to the rail's own document:x402/payment-responseororg.paymentauth/receipt. A2A puts the delivery summary in artifact metadata. Buyers receive receipts through these responses; the receipts API belongs to the merchant._meta["agent-commerce/delivery"]json { "requestId": "…", "resourceId": "premium_report", "receiptId": "…", "deliveredAt": "…", "payment": { "status": "settled", "amount": "0.01", "currency": "USDC", "externalReference": "0x4f2c…9ab1", "network": "eip155:8453" } }A payment in the network's default USDC records
USDCeven when the resource is priced inUSD. On Solana,transactionis the transaction signature.If the gateway delivered but could not save the receipt, the MCP and A2A summary keeps
receiptIdand addsreceiptCommitted: false; the field is absent when the receipt was saved.
Check a signed quote
A merchant can enable experimental ERC-8409 quotes. The x402 challenge then carries, for each EVM offer, an EIP-712 quote in extensions["erc-8409"].info.quotes, over HTTP, MCP and A2A. Each entry holds chainId, signature and a quote whose fields are strings. Match a quote to its offer by chain, asset, recipient and amount in base units, then recover the signer and compare it with payments.x402.quote.issuer from the same origin:
import { recoverTypedDataAddress } from "viem";
const { chainId, quote, signature } =
paymentRequired.extensions["erc-8409"].info.quotes[0];
const signer = await recoverTypedDataAddress({
domain: { name: "Signed Service Payment Quotes", version: "1", chainId: Number(chainId) },
types: {
ServicePaymentQuote: [
{ name: "issuer", type: "address" },
{ name: "payer", type: "address" },
{ name: "recipient", type: "address" },
{ name: "asset", type: "address" },
{ name: "amount", type: "uint256" },
{ name: "requestScheme", type: "string" },
{ name: "requestHash", type: "bytes32" },
{ name: "quoteId", type: "bytes32" },
{ name: "validAfter", type: "uint64" },
{ name: "validUntil", type: "uint64" },
],
},
primaryType: "ServicePaymentQuote",
message: {
...quote,
amount: BigInt(quote.amount),
validAfter: BigInt(quote.validAfter),
validUntil: BigInt(quote.validUntil),
},
signature,
});
// Accept only if signer is the published issuer, requestHash is your own,
// and the window is openrequestHashis Keccak-256 of the RFC 8785 serialization of{ "input": <input>, "resource": <resource id> }, where the input is the JSON body, tool arguments or A2Ainputafter removing the top-level_payment,_authorizationand__proto__fields. Omitted input is{}.- Every quote is a bearer quote:
payeris the zero address, and moving funds still takes your own signed authorization. The gateway does not enforce the quote window, so checkvalidAfterandvalidUntilbefore signing and before resubmitting. - Quotes are informational. Verification and settlement ignore echoed quotes, and Solana, NEAR and MPP offers carry none.
Paying with MPP
When a resource's first enabled rail is MPP, the exchange has the same shape with different carriers:
| Surface | Challenge | Proof |
|---|---|---|
| HTTP | WWW-Authenticate: Payment … on the 402 | Authorization: Payment … |
| MCP | _meta["org.paymentauth/payment-required"], and payment.envelope.challenges in the envelope | a credential object in _meta["org.paymentauth/credential"], or the complete Authorization: Payment … credential in _payment |
| A2A | payment.accepts holds the challenge; payment.envelope.wwwAuthenticate its serialized header value | the complete Authorization: Payment … credential in _payment, the only MPP carrier on A2A |
The buyer's EIP-3009 nonce must equal the challenge hash, so one authorization answers exactly one challenge. Over HTTP, a request with a body must be resent byte for byte, because the challenge binds its digest. A delivered response carries Payment-Receipt as well as PAYMENT-RESPONSE. The pinned client is [email protected]; credentials from mppx 0.12 or earlier fail with wrong_nonce.
When it fails
| Code | HTTP | What to do |
|---|---|---|
PAYMENT_INVALID | 402 | the proof is wrong, or already spent on chain — sign again against the fresh challenge in details.challenge |
PAYMENT_INVALID with permit2_allowance_required | 412 | the buyer has not approved Permit2 for the token; approve it, then pay the fresh challenge in PAYMENT-REQUIRED or details.challenge |
PAYMENT_REPLAYED | 409 / 402 | 409: the first attempt is unfinished or uncertain; do not resend this proof. 402: it already settled, or the MPP credential was used; sign a new proof for the fresh challenge. details.network names the network paid |
PAYMENT_PROVIDER_UNAVAILABLE | 503 | no payment verdict; retryable with the same proof after Retry-After (5 seconds) |
PAYMENT_SETTLEMENT_FAILED | 402 / 502 | 402: settlement was refused and no funds moved; x402 may retry the same proof, while MPP needs a new challenge. 502: settlement could not be confirmed and no receipt was written; ask the merchant to check with agent-commerce reconcile before paying again. Inconclusive results need a later retry or investigation |
BACKEND_ERROR / BACKEND_TIMEOUT | 502 / 504 | without PAYMENT-RESPONSE: nothing settled; x402 can resend the same proof while it is valid, MPP signs a fresh credential. With it: upfront payment settled but delivery failed; keep the summary for the merchant (code BACKEND_ERROR) |
An x402 proof refused at verification, or a replay answered with 402, gets a fresh PAYMENT-REQUIRED whose error names the reason; a refused settlement gets only PAYMENT-RESPONSE with success: false. For a replay that is the paid network's own code: invalid_exact_evm_nonce_already_used, invalid_exact_near_payload_delegate_action_nonce_already_used, or duplicate_settlement on Solana. An x402 HTTP client that decodes PAYMENT-REQUIRED only on 402 must read the 412 challenge itself.
cache-control: no-store. Never cache or reuse one.