Voidly

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 steps

Use 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.

Read the wallet setup and recovery guide

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.

Read-only discovery · illustrative input
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.

What the guarded transport reads · one attempt
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.
Canonical input digest · validated JSON
// 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)

03 / GUARDED X402 FETCH

Authorize one token transfer.

Use @x402/fetch with a buyer-owned adapter that checks the current 402, persists the quote ID, rights manifest digest, payment key and authorization before the signed retry, and stops a second signed attempt. The stock wrapper sends the same URL and JSON body on its paid retry. The buyer reviews the 402 terms, then signs a token payment authorization for the stated transfer. EIP-3009 does not sign the listing, request body, or service URL. The identifiers below are integration hooks you must supply; this outline is not a ready-to-run funded client.

Save the quote ID from the 402 resource URL. Derive paymentKey as keccak256 of the UTF-8 string network|lowercase asset|lowercase payer|lowercase nonce from the original authorization. Keep signer material and the payment signature out of public logs.

One guarded request · TypeScript outline
import { x402Client, wrapFetchWithPayment } from '@x402/fetch'
import { registerExactEvmScheme } from '@x402/evm/exact/client'

// Supply these from your buyer wallet and durable policy/journal adapter.
// The adapter checks the 402 against expectedInputSha256, records the
// original quote, rights digest and authorization, and permits one signed attempt.
const client = new x402Client()
registerExactEvmScheme(client, { signer: configuredBuyerSigner, networks: ['eip155:8453'] })
const paidFetch = wrapFetchWithPayment(inspectAndJournalFetch, client)

const response = await paidFetch(current.callUrl, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body,
})
// A returned 200 still needs signed receipt, work-envelope and byte verification.

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.

Delivery verification checklist
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.

Payer-signed result read · original attempt
// 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.