DEV Community

Cover image for Can an agent buy from you?
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

Can an agent buy from you?

What we learned building a buyer, a seller and a receipt tape for the machine economy

A technical walkthrough of x402-agent-shopper — the demo I'm going to run at Devcon 8 Mumbai.


Ask a room of developers whether an agent can buy something on the internet and most will say yes. Ask them who the agent buys from, and the room goes quiet. That silence is the gap this project lives in.

MCP gave us a handshake: an agent can find a tool and call it. x402 gave us the money: an HTTP 402 with a machine-readable price, an EIP-3009 signature, a settled call. Neither of them tells the agent what it just bought, whether the seller is the agent it claims
to be, or what to do when the bytes come back wrong. That is not a payment problem. It is a contract problem, and it is the next thing every builder in this room is going to hit.

So we built the smallest honest program that demonstrates all of it: discovery, a real
402, a real signature, a real settlement header, a verifiable result, a refusal — and a
receipt tape that shows every step with its price on screen.

01 DISCOVER  8 candidates for "timezone"                       bazaar
   └ geospatial.openverbs.com $0.0020  ·  agentsvc.io $0.0020  ·  2s.io $0.0025
02 SELECT    geospatial.openverbs.com  Resolve the timezone… $0.0020
03 PAY       geospatial.openverbs.com · $0.0020 · settled      230ms
   └ 402 header · x402 v2 · 1 offer(s) · also MPP rail "evm"
04 RESULT    Asia/Kolkata +05:30
...
TOTAL SPENT  $0.057   ·  3 sellers touched  ·  2.6s
Enter fullscreen mode Exit fullscreen mode

Three sellers. Two of them are strangers. The whole errand costs five cents, which is the
point: the money is no longer the hard part.


The architecture is deliberately boring

                      ┌────────────────────────────────┐
                      │            run.mjs             │
                      │  goal → find → check → pay     │
                      │       → verify → receipt       │
                      └──────┬─────────────────┬───────┘
                             │                 │
                             ▼                 ▼
                  ┌────────────────────┐   ┌───────────────────────┐
                  │ lib/discovery.mjs  │   │ lib/guard.mjs         │
                  │ Bazaar search      │   │ Budget.approve()      │
                  └─────────┬──────────┘   │ ./STOP kill switch    │
                            │              └──────────┬────────────┘
                            ▼                         │ every signature
                  ┌─────────────────────┐             │
                  │    lib/x402.mjs     │◄────────────┘
                  │ readChallenge       │
                  │ chooseAccept · sign │
                  └────┬───────────┬────┘
                       │           │
          ┌────────────┘           └──────────────┐
          ▼                                       ▼
┌──────────────────────┐        ┌───────────────────────────────────────┐
│ strangers' 402s      │        │ seller/server.mjs (:8787)             │
│ timezone · verdict   │        │ /llms.txt · /.well-known/x402         │
│ (outside our control)│        │ /.well-known/schema.json              │
└──────────────────────┘        │ /buy/certificate  50000 μUSDC         │
                                └──────────────┬────────────────────────┘
                                               ▼
                                ┌───────────────────────────────────────┐
                                │ seller/certificate.mjs                │
                                │ canonical JSON → ed25519 → offline    │
                                └───────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Node 22, ESM, one dependency (viem), no build step, no framework. Every file fits on a
screen because the audience has to read it from the back of a conference room. That
constraint is not aesthetic — it is the reason the code is legible enough to be believed.


The money path, in three functions

1. Read the challenge

A 402 is not a standard document yet. Some sellers put the offer in the body, some in a
payment-required header. Networks come as base, base-mainnet or eip155:8453.
Amounts come as amount or maxAmountRequired. So the reader normalises all of it:

let dialect = 'body';
if (!body?.accepts && headerValue) {
  body = JSON.parse(Buffer.from(headerValue, 'base64').toString('utf8'));
  dialect = 'header';
}

const accepts = parseAccepts(body);   // network aliases, amount field names, payTo aliases
Enter fullscreen mode Exit fullscreen mode

2. Sign, then negotiate the header

The credential is an EIP-3009 TransferWithAuthorization: the buyer authorises a transfer,
the facilitator pays the gas, nobody needs an account or an invoice.

const header = await signAuthorization(accept, account);
Enter fullscreen mode Exit fullscreen mode

And then the part nobody warns you about. Three header names are in the wild, and one live
seller rejects a request that offers both an x402 and an MPP credential:

400 ambiguous_payment: provide either x402 (PAYMENT-SIGNATURE) or MPP (Authorization), not both.
Enter fullscreen mode Exit fullscreen mode

So the client tries, in order, and backs off on that exact error:

const attempts = [
  { 'payment-signature': header, 'x-payment': header },
  { 'payment-signature': header },
];
if (challenge.mpp) attempts.push({ authorization: `Payment ${header}` });

let second = null;
for (const credentials of attempts) {
  second = await send(credentials);
  if (second.status !== 400) break;
  const probe = await second.clone().text();
  if (!/ambiguous/i.test(probe)) break;
}
Enter fullscreen mode Exit fullscreen mode

3. Gate the spend before you sign

An autonomous agent with a card is a liability. The budget is not a report you read
afterwards; it is a gate in front of the signing function:

approve({ url, amount, network, payTo }) {
  if (existsSync(this.limits.killSwitch)) {
    throw new Refused('kill switch present', `remove ${this.limits.killSwitch} to resume`);
  }
  if (!this.limits.networks.includes(network)) {
    throw new Refused('network not allowed', `${network} is not in the allowlist`);
  }
  if (BigInt(amount) > this.limits.maxPerCall) {
    throw new Refused('price above per-call cap', `${amount} > ${this.limits.maxPerCall}`);
  }
  if (this.spent + BigInt(amount) > this.limits.maxTotal) {
    throw new Refused('session budget exhausted', `${this.spent + BigInt(amount)} > ${this.limits.maxTotal}`);
  }
  this.spent += BigInt(amount);
  return true;
}
Enter fullscreen mode Exit fullscreen mode

touch STOP and every future purchase refuses. On stage that beats a config flag, because
the audience can see the file.


Five things only live endpoints can teach you

We built this against the real x402 Bazaar, not against a specification. Every one of these
was a bug found by running the code:

  1. The header name moved. v1 sellers read X-PAYMENT, v2 read PAYMENT-SIGNATURE, MPP reads Authorization: Payment. Hardcode one and you work on half the market.
  2. A 402 can carry two rails. geospatial.openverbs.com returns an x402 USDC offer and a WWW-Authenticate: Payment envelope naming a second method. The agent has to choose a rail, not just a price. Our tape prints also MPP rail "evm" so the room sees the fork.
  3. The input shape is not in the price. The listing's own Bazaar extension says the tool takes a JSON body {lat, lon} on POST. Our first version sent a GET with query parameters — a payment that returns a validation error. The client now posts what the listing declares.
  4. An authorisation is single-use. Without a used-nonce set, a replayed credential mints a second certificate. Both our sellers now refuse with authorisation already used, and the contract test asserts it.
  5. Unfunded is not malformed. A correctly signed payment from an empty wallet gets an empty 402 back — not an error, not a message. That is the failure you cannot fix on stage, so it is the one to rehearse.

None of this is in a spec. All of it is in the code, and all of it is the talk.


The result has to be verifiable, or it is just bytes

The last hop sells a certificate. The seller canonicalises a JSON document, hashes it,
signs the hash with an ed25519 key, and returns both:

const { certificate, publicKey } = mint({ buyer: account.address, item, result });
const verification = check(certificate, publicKey);   // → { valid: true, digest }
Enter fullscreen mode Exit fullscreen mode

The buyer verifies with the public key only. No callback, no registry lookup, no trust in
the seller's uptime. A receipt that requires the seller to still exist is not a receipt.


Testing a payment system without spending money

Three suites, and each one is designed to make a specific embarrassment impossible:

  • test/e2e.mjs — 12 checks. Spawns a seller on an ephemeral port, generates a throwaway wallet, runs the full 402 → sign → verify → mint → offline-verify loop. No funded wallet, no network.
  • test/contract.mjs — 27 checks. The refusal matrix: bad signature, replayed authorisation, garbage credential, underpayment, wrong network, kill switch, missing key. Every case asserts a status and a machine-readable reason. An agent meets failure states long before it meets a 200.
  • test/offline.mjs — 3 checks. Replaces globalThis.fetch with a landmine that logs NETWORK-CALLED and throws, then runs the fixtures mode. This is how you prove the backup demo is actually offline instead of hoping.

The contract test earned its keep immediately: it caught the seller accepting a replayed
credential, and it caught our checker forcing https on a localhost target — a bug that
would have surfaced live.


What we deliberately left out

  • No facilitator. Settlement is verified, not broadcast, so the money path has no third party in it. Wiring a real facilitator is one POST /settle — lib/facilitator.mjs is the seam.
  • No LLM. The agent is deterministic. The interesting part is the protocol; a model would add latency and doubt.
  • No database, no Docker, no framework. Anything that makes the demo harder to read from twelve rows back is a cost, not a feature.

Where this goes next

The obvious next steps are the ones we would take ourselves if this were a product:
real facilitator settlement, refunds for post-payment failure, idempotency keys, an MCP
surface so a model can drive the same engine under the same budget guard, and proper
multi-rail support instead of only detecting the MPP rail.

But the important one is smaller: publish the four documents. llms.txt,
/.well-known/x402, a typed schema and an agent card. Right now an agent that wants to buy
from you has to guess. That is not a pricing problem and it is not a payments problem — it
is a documentation problem, and it is the one thing in this stack every builder can fix
this week.

The receipt is the product. Publish enough for someone to earn one.


Code: x402-agent-shopper — run node run.mjs --fixtures to see the whole tape with no network
and no money. Then node test/contract.mjs, because the failure states are the interesting
part.

Agent Shopper Demo

Code & more: https://www.dailybuild.xyz/project/301-x402-agent-shopper

Top comments (0)