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
- 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.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 */ } } } - 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'); - 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 rpcUrl, accepts, // one entry of payment.accepts, verbatim }); - 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 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
_metaunderagent-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
| Code | HTTP | What to do |
|---|---|---|
PAYMENT_INVALID | 402 | the proof is wrong — request a fresh challenge and sign again |
PAYMENT_REPLAYED | 409 | that authorisation was already used; never resend it |
PAYMENT_PROVIDER_UNAVAILABLE | 503 | retryable — the rail is down, no money moved |
PAYMENT_SETTLEMENT_FAILED | 502 | settlement did not complete; nothing was delivered |
BACKEND_ERROR / BACKEND_TIMEOUT | 502 / 504 | paid but not delivered — keep PAYMENT-RESPONSE for the merchant |
cache-control: no-store. Never cache or reuse one.