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 an EIP-3009 authorisation for exactly that amount to exactly that recipient, and repeats the same call with the proof attached.

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.

    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": "0x…",
        "network": "eip155:84532",
        "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.

    typescript
    const accepts = envelope.payment.accepts[0];
    
    if (accepts.network !== 'eip155:8453') throw new Error('unexpected network');
    if (accepts.payTo.toLowerCase() !== EXPECTED_MERCHANT.toLowerCase()) throw new Error('unexpected recipient');
    if (accepts.asset.toLowerCase() !== USDC_ON_BASE.toLowerCase()) throw new Error('unexpected asset');
    if (BigInt(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
      rpcUrl,
      accepts,           // one entry of payment.accepts, verbatim
    });
  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 also when the backend fails after settlement:

    PAYMENT-RESPONSE (decoded)json
    {
      "success": true,
      "transaction": "0x4f2c…9ab1",
      "network": "eip155:84532",
      "status": "settled",
      "provider": "x402",
      "amount": "0.01",
      "currency": "USDC"
    }

    Over MCP the delivery summary rides in the tool result's _meta under agent-commerce/delivery (over A2A, in the artifact metadata under the same key). It is the buyer's only route to its receipt — 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:84532"
      }
    }

When it fails

CodeHTTPWhat to do
PAYMENT_INVALID402the proof is wrong — request a fresh challenge and sign again
PAYMENT_REPLAYED409that authorisation was already used; never resend it
PAYMENT_PROVIDER_UNAVAILABLE503retryable — the rail is down, no money moved
PAYMENT_SETTLEMENT_FAILED502settlement did not complete; nothing was delivered
BACKEND_ERROR / BACKEND_TIMEOUT502 / 504paid but not delivered — keep PAYMENT-RESPONSE for the merchant
A challenge is per request, with a fresh expiry, and is sent with cache-control: no-store. Never cache or reuse one.