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

  1. Call without a proof

    HTTP answers 402 with the envelope in the body and the base64 x402 v2 PaymentRequired document in the PAYMENT-REQUIRED header. MCP answers isError: true with the envelope in structuredContent; for x402 the v2 PaymentRequired fields 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 */ }
      }
    }
  2. 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.accepts holds one entry per offer. With several, the envelope omits destination, network and asset, which are deprecated anyway: pick the offer you will pay and check that entry. Your proof echoes it in accepted, 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');
  3. Sign the proof

    Any x402 v2 client can consume payment.envelope or the PAYMENT-REQUIRED header 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.network

    createPaymentProof signs EIP-3009 offers only; an offer whose extra.assetTransferMethod is anything else is refused with INPUT_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.

  4. 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 '{}'
  5. Read your receipt

    Over HTTP the settlement summary comes back as base64 JSON in the PAYMENT-RESPONSE header on success and when the backend fails after an upfront settlement. A refused or unconfirmed settlement sends success: false with an errorReason. When the provider reports amount, 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 _meta under agent-commerce/delivery, next to the rail's own document: x402/payment-response or org.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 USDC even when the resource is priced in USD. On Solana, transaction is the transaction signature.

    If the gateway delivered but could not save the receipt, the MCP and A2A summary keeps receiptId and adds receiptCommitted: 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:

typescript
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 open
  • requestHash is Keccak-256 of the RFC 8785 serialization of { "input": <input>, "resource": <resource id> }, where the input is the JSON body, tool arguments or A2A input after removing the top-level _payment, _authorization and __proto__ fields. Omitted input is {}.
  • Every quote is a bearer quote: payer is the zero address, and moving funds still takes your own signed authorization. The gateway does not enforce the quote window, so check validAfter and validUntil before 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:

SurfaceChallengeProof
HTTPWWW-Authenticate: Payment … on the 402Authorization: Payment …
MCP_meta["org.paymentauth/payment-required"], and payment.envelope.challenges in the envelopea credential object in _meta["org.paymentauth/credential"], or the complete Authorization: Payment … credential in _payment
A2Apayment.accepts holds the challenge; payment.envelope.wwwAuthenticate its serialized header valuethe 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

CodeHTTPWhat to do
PAYMENT_INVALID402the proof is wrong, or already spent on chain — sign again against the fresh challenge in details.challenge
PAYMENT_INVALID with permit2_allowance_required412the buyer has not approved Permit2 for the token; approve it, then pay the fresh challenge in PAYMENT-REQUIRED or details.challenge
PAYMENT_REPLAYED409 / 402409: 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_UNAVAILABLE503no payment verdict; retryable with the same proof after Retry-After (5 seconds)
PAYMENT_SETTLEMENT_FAILED402 / 502402: 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_TIMEOUT502 / 504without 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.

A challenge is per request, with a fresh expiry; over HTTP the 402 is sent with cache-control: no-store. Never cache or reuse one.