Agent buyer guide · Base mainnet
Buy a service with an agent.
Discover a plain-JSON seller service, inspect the current x402 quote, make one guarded request, verify signed delivery and work evidence, and recover the same attempt if the result is uncertain.
This guide assumes a configured buyer signer and durable policy and payment journal. The stock fetch wrapper handles the HTTP 402 exchange; it does not supply your spend limits, restart recovery, or receipt verification. A catalog listing is not a completed order.
Read the five stepsUse the local wallet CLI.
Agent Wallet Kit 0.2.0 includes buy for ordinary, versioned seller POST listings. Choose an exact listing ID and version, an input file, a network, per-call and daily caps, and a purchase ceiling. Negotiated work uses its own agreement flow.
The CLI requires an existing encrypted local wallet and durable state. Its spending caps cover calls made through the kit's local ledger. --dry-run checks only local arguments and JSON; it does not load a wallet, contact the service, sign or pay. Every invocation requires --network base-sepolia or --network base; mainnet uses real USDC.
Keep the original quote and attempt records. If a result is uncertain, use attempts and recover for that original quote. Do not start another purchase to recover it. A refund_owed result does not mean the refund has been paid.
01 / DISCOVERY
Find a current service.
Search public /v1/services/match by keyword, then read the returned detailUrl for the exact listing version and input shape. This example selects plain-json output; buyer-encrypted output needs a separate request envelope. Match is keyword based, not semantic ranking. If detail returns 409, discover again. The catalog price is a discovery hint; the later 402 defines the current payment terms.
const API = 'https://x402.voidly.ai'
const match = await fetch(API + '/v1/services/match?capability=country&network=eip155%3A8453&limit=4')
if (!match.ok) throw new Error('Discovery failed: ' + match.status)
const { items } = await match.json()
const item = items.find((row: { kind?: string; outputPrivacy?: string }) =>
row.kind === 'seller' && row.outputPrivacy === 'plain-json')
if (!item) throw new Error('No matching plain-JSON seller service')
const detailResponse = await fetch(item.detailUrl)
if (detailResponse.status === 409) throw new Error('Listing changed; discover again')
if (!detailResponse.ok) throw new Error('Detail failed: ' + detailResponse.status)
const { item: current } = await detailResponse.json()
if (current.id !== item.id || current.version !== item.version) throw new Error('Listing changed')
if (current.outputPrivacy !== 'plain-json') throw new Error('This guide covers plain-JSON output')
// Save current.rights.digest and declared uses for comparison and rights review.
// Validate your intended JSON input against current.inputSchema.
// Use current.callUrl for the paid request.02 / PAYMENT TERMS
Inspect the 402 for this attempt.
The guarded fetch in step 3 first sends the unsigned JSON request and receives the short-lived 402. That request creates a quote and counts against the call rate limit. Before a signer is used, your adapter must check the quoted resource URL, listing ID and version, output privacy mode, input digest, network, token, seller wallet, expiry, rights manifest digest, and atomic amount against its saved intent and an independent spending cap. Reject any mismatch or required work-purchase signature; negotiated work needs its own buyer agreement flow. A separate manual 402 probe would create a different quote; a later wrapped call cannot be described as paying that earlier quote.
To check the input digest, parse the exact JSON body you will send. Recursively sort object keys, keep array order, serialize values with JSON.stringify, hash the canonical UTF-8 bytes with SHA-256, then prefix lowercase hex with 0x. The gateway validates parsed JSON first and applies its own size, depth, and key rules; hashing raw wire bytes can disagree even when JSON values are equivalent.
One POST to the selected callUrl, with JSON matching inputSchema:
first response: HTTP 402 + PAYMENT-REQUIRED (x402 v2)
resource.url: this call with its short-lived quote ID
accepts: exact · eip155:8453 · current Base USDC asset
atomic amount · seller payTo · timeout
intent.info (Voidpay extension): listing ID/version · input digest · quote expiry
outputPrivacy · rightsManifestSha256 (nullable)
intent.info.optionalBuyerSignatureHeader: x-voidpay-intent-signature
Your guarded fetch adapter reads and checks this response before it lets
@x402/fetch sign or retry. Reject a required work-purchase signature;
that negotiated work flow is outside this example.// Work from the exact JSON body that will be sent, after schema validation.
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
const object = value as Record<string, unknown>
return '{' + Object.keys(object).sort().map(key =>
JSON.stringify(key) + ':' + canonicalJson(object[key])
).join(',') + '}'
}
async function inputDigest(body: string): Promise<string> {
const parsed = JSON.parse(body)
const bytes = new TextEncoder().encode(canonicalJson(parsed))
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
return '0x' + Array.from(hash, byte => byte.toString(16).padStart(2, '0')).join('')
}
const body = JSON.stringify(validatedInput)
const expectedInputSha256 = await inputDigest(body)04 / SIGNED DELIVERY
Verify the receipt, work evidence and bytes.
A successful HTTP response alone is not proof of delivered work. Verify the gateway's Ed25519 receipt and separate signed work envelope against its public key registry, then compare them with the original attempt and exact response bytes. PAYMENT-RESPONSE is payment evidence; it does not replace the delivery receipt. confirmationsAtDelivery is one provider observation, not chain finality.
A null or unknown rights declaration grants no reuse permission. The frozen digest binds a seller declaration; it does not reveal private custom terms or establish legal clearance. Review the declared uses and obtain any needed terms or rights before reuse. Historical v1 results may have a receipt without a work envelope; treat them as receipt-only evidence.
From a new paid response:
1. Read x-voidpay-delivery-receipt, x-voidpay-work-envelope
and PAYMENT-RESPONSE.
2. Fetch https://x402.voidly.ai/.well-known/voidpay-receipt-keys.json.
3. Verify the receipt's Ed25519 signature with its keyVersion.
4. Require signed status = delivered. Compare network and transaction
hash with PAYMENT-RESPONSE. Compare asset, payer, payTo and amount
with the saved 402 and authorization.
5. Compare listing ID/version, quote ID, resource URL and input SHA-256
with the saved attempt. Hash the exact returned response bytes and
compare that SHA-256 with the signed output digest.
6. Decode the work envelope. Verify its separate Ed25519 signature over
"voidpay-work-envelope-v1\n" + sorted-key JSON payload without signature,
using envelope.keyVersion from the key registry. This can differ from
receipt.keyVersion.
7. Match envelope.receiptSha256 to SHA-256 of the canonical v1 receipt
payload without signature; match receiptSignature to the verified
receipt signature. Match receiptKeyVersion, paymentKey, quoteId and
status to the receipt and saved attempt.
8. Match envelope.rightsManifestSha256 to the accepted 402
intent.info rightsManifestSha256 and saved public rights summary digest.
For this ordinary call, require acceptedTermsSha256,
listingSnapshotSha256 and purchaseIntentSha256 to be null.
A decoded receipt or envelope without signature verification is not proof.05 / ORIGINAL ATTEMPT
Recover before any new authorization.
After a timeout or unclear response, keep the saved quote and payment key. The original payer signs the exact EIP-191 recovery message and reads the same attempt's result. A delivered result can return the original bytes and, on new rows, a signed work envelope during the retained recovery window of up to 24 hours. A signed refund_owed receipt (and work envelope on new rows) records an obligation; it is not a completed refund. A 404 or pending result does not prove no transfer occurred. An eligible settlement_pending order has a separate signed, bodyless resume path for the original attempt. Do not automatically start another payment.
// Use the original payer and values saved before the signed request.
const message = [
'Voidpay Marketplace recovery v1',
'chainId:' + chainId,
'paymentKey:' + paymentKey,
'quoteId:' + quoteId,
].join('\n')
const signature = await signMessage(message) // EIP-191, original payer
const result = await fetch(
API + '/v1/services/' + listingId + '/quotes/' + quoteId + '/result',
{ headers: {
'x-voidpay-recovery-payment-key': paymentKey,
'x-voidpay-recovery-signature': signature,
} },
)
// Verify returned signed receipt and work envelope; hash delivered bytes as above.