DEV Community

SWARM operator
SWARM operator

Posted on Originally published at swarm-agent.net AI-assisted

x402 seller gotchas: what broke our 402, and how we fixed it

We run SWARM, an autonomous agent that sells four small services over x402, paid per request in USDC on Base. We spent a month getting it right. By the end it was listed on x402scan and graded A on x402-list, 14 of 14 checks.

And on the morning we sat down to write this up, we found that a buyer using the official x402 v2 client could not pay us at all. Grade A and all. This is every error we hit, with the exact message where we have it, what was actually going on, and the fix.

The lesson that matters most: directories don't pay. They check your quote, not your checkout. The only test that catches payment bugs is buying from yourself, with the official client, the way a stranger would. Our own test buyer didn't catch them either, because we wrote it in the same outdated dialect as our server.

Being found
x402scan finds no discovery document
The first thing x402scan told us when we registered: it couldn't find a discovery document, a machine-readable list of what you sell. A working 402 on its own isn't enough to be listed.

Fix: serve /openapi.json describing each paid route, with an x-payment-info block per operation. We also serve /.well-known/x402. Generate both from the same list of offers your 402 uses, so they can never disagree with it.

Two warnings x402scan shows once it does find it, both worth fixing:

Add info.contact.email to your openapi.json to verify ownership and let users contact you.
Serve a /favicon.ico at your API root to display an icon.
[404] No valid x402 response found
Our paid routes were POST-only. The prober asked with a different method and got a 404, so as far as it was concerned the route wasn't there.

Fix: answer the 402 quote on GET and HEAD too, not only on the method that does the work. Quoting a price costs nothing, so there's no reason to hide it behind a method.

[402] No valid x402 response found
The confusing one: the tool tells you it found no valid 402 while your server is returning a 402. Ours had the payment requirements in the JSON body only. x402 v2 puts them in a header. From the x402 docs:

PAYMENT-REQUIRED: Contains the Base64-encoded payment requirements from the server. This header is returned alongside the 402 status code.

Other people hit the mirror image: requirements in the header only, empty body, and clients that read the body can't pay. Send both. And expose the header, or browser-based clients can't read it:

js
const body = JSON.stringify(paymentRequired);
res.writeHead(402, {
"Content-Type": "application/json",
"PAYMENT-REQUIRED": Buffer.from(body, "utf8").toString("base64"),
"Access-Control-Expose-Headers": "PAYMENT-REQUIRED",
});
res.end(body);
Dollars in one place, atomic units in the other
The same price is written two different ways, and mixing them up gets you flagged as a malformed amount:

Where 0.02 USDC is written as
openapi.json → x-payment-info.price.amount "0.020000" (US dollars, decimal)
the 402 → accepts[].amount "20000" (atomic units, USDC has 6 decimals)
json
"x-payment-info": {
"price": { "mode": "fixed", "currency": "USD", "amount": "0.020000" },
"protocols": [{ "x402": {} }]
}
Relative resource URLs
Behind a reverse proxy, our server only saw its own path and put /services/summary in the 402's resource field. Anything that indexes your 402 needs the full address. Build it from your public base URL (or X-Forwarded-Proto + Host if you trust your proxy), never from what the app sees locally.

Let the prober call you
x402scan's own hint when an endpoint fails is worth reading closely:

They need to return a 402 payment challenge — ensure the x402 paywall runs before request validation, or mark the required parameters in your OpenAPI spec so we can probe automatically.

Two fixes in one sentence. Quote before you validate: a request with no payment and no body should get the 402, not a 400 about missing fields. And describe your inputs (an input schema and a real example per offer) so a prober, or a buyer's agent, knows how to call you.

Getting paid
X-PAYMENT header is required
Our 402 said "x402Version": 2. Our server read the payment from X-PAYMENT, which is the v1 name. A v2 client takes you at your word and sends the payment in PAYMENT-SIGNATURE. We didn't see it, answered with another 402, and the buyer was stuck in a loop: pay, get asked to pay. Nothing was charged, but nothing was sold either.

v1 v2
buyer sends payment in X-PAYMENT PAYMENT-SIGNATURE
seller returns receipt in X-PAYMENT-RESPONSE PAYMENT-RESPONSE
Source: x402 migration guide, v1 to v2.

Fix: accept both, new name first. And allow it through CORS, or a browser won't even send it:

js
const raw = req.headers["payment-signature"] ?? req.headers["x-payment"];
// Access-Control-Allow-Headers: Content-Type, PAYMENT-SIGNATURE, X-PAYMENT
Quick way to check your own server without spending anything: send garbage in each header and compare the errors. If the new one says the header is missing while the old one says it's malformed, you're only reading the old one.

bash
curl -s -X POST -H "X-PAYMENT: junk" https://your-api/paid-route
curl -s -X POST -H "PAYMENT-SIGNATURE: junk" https://your-api/paid-route
unsupported scheme: undefined
Fixing the header name isn't enough, because the payload changed shape too. In v1, scheme and network sat at the top of the payment payload. In v2 they live inside accepted, the option the buyer picked from your quote. From the type in @x402/core 2.25.0:

ts
type PaymentPayload = {
x402Version: number;
resource?: ResourceInfo;
accepted: PaymentRequirements;
payload: { [key: string]: unknown };
extensions?: { [key: string]: unknown };
};
A hand-written v1 decoder looks for p.scheme, finds nothing, and rejects a perfectly good payment. The signature and authorization inside payload are the same in both versions for the exact scheme, so the fix is small:

js
const p = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
const scheme = p.accepted?.scheme ?? p.scheme; // v2 : v1
const network = p.accepted?.network ?? p.network; // v2 : v1
paymentStatus: none
After fixing the two above, a purchase with the official client worked: 200 in 1.1 s, the USDC moved. And the client reported:

paymentStatus = none
The buyer paid, and their software believes they didn't. We were settling, writing the transaction to our own ledger, and returning only the product, with no receipt. An agent keeping its own books records $0 spent, and its owner later finds a charge the agent says it never made. That's the opposite of trust.

Fix: return the settlement as a receipt, base64 JSON, in both header names:

js
const receipt = Buffer.from(JSON.stringify({
success: true,
transaction: settle.transaction,
network,
payer, // authorization.from
}), "utf8").toString("base64");
res.setHeader("PAYMENT-RESPONSE", receipt); // v2
res.setHeader("X-PAYMENT-RESPONSE", receipt); // v1
res.setHeader("Access-Control-Expose-Headers", "PAYMENT-RESPONSE, X-PAYMENT-RESPONSE");
Same purchase afterwards: paymentStatus = settled.

A bad signature returns 500
Given a malformed signature, the facilitator doesn't always answer "not valid". It can throw, and if nothing catches it, your buyer gets a 500, which tells them your server is broken. What we got back from a deliberately fake signature:

invalid_exact_evm_payload_signature: invalid signature: public key recovery code 44 is not in the valid range [27, 34]
Fix: catch it, and tell the two cases apart. A payment problem is a 402 with the reason. Anything else (the facilitator being down) is a 503: telling a buyer "your payment is bad" when the problem is on your side makes them re-sign in a loop.

js
try {
result = await facilitator.verify(payload, requirements);
} catch (e) {
const msg = String(e?.message ?? e);
if (/invalid|insufficient|expired|mismatch/i.test(msg))
return send402(facilitator rejected payment: ${msg});
return send503("payment verifier unavailable. Nothing was charged; retry later.");
}
Delivering what was paid for
Promising 360 s behind a 300 s proxy
Two separate clocks, both ours. First, we built payment requirements without maxTimeoutSeconds, so every offer promised the 60-second default. Our full report takes two to four minutes. Second, once we fixed that to 360 s, nginx in front of it was still set to cut upstream responses at 300 s. We found this one by reading the config, before it caught a buyer, but it would have been the worst kind: payment settled, nothing delivered.

Fix: set maxTimeoutSeconds per offer to what the work really takes, say it in the description, and keep every proxy in front above it:

nginx
location /services/ {
proxy_pass http://127.0.0.1:8004;
proxy_read_timeout 420s; # above the longest promise (360 s)
proxy_send_timeout 420s;
}
Charging before delivering
Our first flow was verify, settle, do the work, respond. If the work failed or the buyer's client gave up waiting, they had paid for nothing, and the only remedy was a manual refund.

Fix: verify, do the work, settle, respond. Verification already proves the payment is good, so a fake signature still can't make you work for free. If the work fails, you don't settle: the buyer keeps their money and you lose the compute. That's the right way round.

verify → do the work → settle → respond with product + receipt
└─ work failed? don't settle. They keep their money.
Signatures that expire mid-job
Settling last creates its own trap. The buyer's authorization has a validBefore. If it expires while you're still working, you deliver and then can't collect, every time, from that buyer.

Fix: after verifying and before starting, check that the authorization lives at least as long as the job can take. If it doesn't, refuse and say exactly what to do:

js
const now = Math.floor(Date.now() / 1000);
const need = requirements.maxTimeoutSeconds;
if (Number(auth.validBefore) < now + need)
return send402(payment authorization expires too soon. +
Re-sign with validBefore >= ${now + need}. Nothing was charged.);
And one that was ours alone
The field we made up
While building the discovery files we copied the shape of a field from another project's manifest instead of from the spec, and shipped it in a form the spec doesn't define. Nothing complained. It was just wrong. Validate your discovery files against the spec, not against somebody else's JSON, however well that project seems to be doing.

Buy from yourself, the way a stranger would
Three of the bugs above (the header name, the payload shape, the missing receipt) were invisible to every directory, because none of them pays, and invisible to our own test buyer, because we'd written it in the same old dialect as the server. They showed up the first time we paid with the official client. Use a separate test wallet, not the one that receives the money.

This is the whole buyer, from the x402 quickstart. By default the official client refuses to pay more than $1 per request.

bash
npm install @x402/fetch @x402/evm viem
js
import { wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch";
import { x402Client } from "@x402/core/client";
import { ExactEvmScheme } from "@x402/evm/exact/client";
import { privateKeyToAccount } from "viem/accounts";

const signer = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
const client = new x402Client();
client.register("eip155:*", new ExactEvmScheme(signer));

const pay = wrapFetchWithPayment(fetch, client);
const res = await pay("https://your-api/paid-route", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ /* your input */ }),
});

const result = await new x402HTTPClient(client).processResponse(res);
console.log(res.status, result.paymentStatus); // want: 200 settled
If you want a live v2 seller to point a client at while you build one, ours answers at https://swarm-agent.net/services/utilities, and the cheapest call is $0.02.

Originally published at swarm-agent.net/x402-seller-gotchas, where it gets updated as we hit new ones.

This article was written with AI assistance. The errors, outputs and fixes above come from our own server and were checked before publishing.

Top comments (0)