Lessons From Shipping 40 Paid x402 Endpoints
We run draconic21-x402-api, a live x402 (HTTP 402, USDC on Base) seller with 40+ paid routes — SEC EDGAR, OFAC sanctions, Treasury data, and a handful of others. These are the real pitfalls that cost us actual debugging time, not a theoretical list. Every one below happened to real, deployed code.
In this guide
- Settle only after the handler actually succeeds
- The 5-tag schema limit that made a route unpayable
- Placeholder Bazaar output schemas quietly hurt conversion
- Discovery indexing only happens after a first settled call
- A deploy that silently ran old code for hours
- Crawler bots that pay the cheapest route, not the best one
- FAQ
- Who's behind this
1. Settle only after the handler actually succeeds
The x402 spec's intended flow is verify, run the handler, settle only if the handler succeeds. It is easy to get the ordering wrong while wiring a hand-rolled payment path, and the failure mode is bad: a buyer gets charged for a response your own upstream failed to produce.
We found exactly this bug in a v1-compat payment path we had built alongside the official v2 SDK middleware. The old code called a combined "verify and settle" helper before running the actual route handler:
// BEFORE (buggy): settle happens before the real handler runs
const outcome = await verifyAndSettleV1Payment(facilitatorClient, payload, requirements);
if (!outcome.ok) { res.status(402).json(...); return; }
req.paid = true;
return next(); // the real handler -- e.g. a live upstream fetch -- runs AFTER settlement
If that handler then hit a flaky upstream (a rate limit, a timeout) and returned a 5xx, the buyer had already been charged in USDC on Base, with no refund path, for a response that never arrived. Not yet exploited in our case — the code path was gated behind a feature flag that was off by default — but it was live and it was wrong.
The fix mirrors what the official @x402/express SDK already does correctly: buffer the response, run the handler, wait for it to finish, and only then check the status code before deciding whether to settle.
// AFTER: buffer the response, run the handler, decide from its real outcome
patchResponseMethods(res); // buffer writeHead/write/end/flushHeaders
await next(); // the real handler runs now, nothing sent yet
await endPromise; // resolves once the handler calls res.end()
if (res.statusCode >= 400) {
restoreResponseMethods(res);
replayBufferedResponse(res); // buyer sees the failure, unpaid
} else {
const settleResult = await settlePayment(facilitatorClient, payload);
if (!settleResult.ok) {
// settle itself failed -- discard the paid body, don't give it away free
respondWithFreshChallenge(res);
} else {
attachReceiptHeader(res, settleResult);
replayBufferedResponse(res); // buyer sees the success, now paid
}
}
The part that's easy to miss: it's not enough to just move settle to "after." You also need to handle settle itself failing on a request whose handler already succeeded — a naive "settle after, flush regardless" fix would quietly give the paid result away for free on a settle failure. Test both directions: handler fails → never settle; handler succeeds but settle fails → don't leak the result either. We added regression tests for all three outcomes (handler fails, handler throws, settle fails after handler succeeds) so this can't silently regress.
2. The 5-tag schema limit that made a route unpayable
x402 v2's official resource-info schema (the metadata a seller attaches to a payment challenge, from @x402/core) caps the tags array at 5 entries. Miss that and it doesn't get silently truncated — a spec-strict buyer client that validates the challenge before attempting payment will simply fail to parse it, meaning a real agent can never pay that route at all.
We hit this on one route out of 40+ when a routine SEO/discoverability tweak added a 6th tag (both an acronym and its spelled-out form, on top of four others already there). Every other route had exactly 5; this one silently had 6, and nothing caught it until an audit that actually validated our own production challenges against the official schema instead of just eyeballing the fields.
Two fixes, not one: trim the offending route back to 5 tags, and add a fail-fast guard at module load time so this can never ship silently again:
for (const [route, tags] of Object.entries(LEAF_TAGS)) {
if (tags.length > 5) {
throw new Error(`route "${route}" has ${tags.length} tags, x402 v2 caps ResourceInfo.tags at 5`);
}
}
That guard now runs on every server start and every test run — the class of bug is caught immediately instead of requiring someone to notice a route quietly stopped being payable.
3. Placeholder Bazaar output schemas quietly hurt conversion
Coinbase's CDP Bazaar (a free x402 discovery index) lets a seller attach a real output schema and example response to a route's discovery listing, so a buyer agent can see the actual response shape before paying. If you skip it, most SDKs fall back to a generic placeholder — something like { brand, product, ok } — which tells a prospective buyer nothing about what they'd actually get.
We shipped real per-field schemas for our newer routes from the start, but two of our oldest, cheapest, most Bazaar-exposed routes were still falling through to the generic placeholder. When we later traced our first confirmed third-party settlement back to a specific route by matching its exact price against our catalog, it turned out to be one of exactly those two weak-schema routes — the ones a buyer (or a buyer-evaluating bot) had the least pre-payment information about. That's not proof the schema caused the sale, but it's a strong argument for not deferring this: fill in the real output schema and a real example for every route you want discovered, not just the newest ones.
4. Discovery indexing only happens after a first settled call
This one surprised us: CDP Bazaar doesn't index a route just because it exists in your .well-known/x402.json catalog with good tags and a rich schema. It indexes a resource path after that path has received at least one real, settled payment. A perfectly-tagged route with zero settled calls can be invisible to Bazaar's free discovery search, while a mediocre-schema route that happened to get paid once shows up.
Practically, this means "add the route" and "make the route discoverable" are two different steps, and the second one costs real money — one small real payment per route, deliberately made, to trigger indexing. We keep a running checklist of which routes still need that one settled call, versus which have already been indexed, because it's easy to ship a good route and forget it's invisible until someone (including yourself) actually pays for it once.
5. A deploy lockfile break that silently ran old code for hours
Our production container pins a specific npm major version. A developer machine running a newer npm regenerated the lockfile in a way the pinned, older npm rejected at npm ci --omit=dev — Missing: zod@x.y.z from lock file — and the deploy failed. The dangerous part wasn't the failure; it was that nothing surfaced it. Two consecutive merges to main went out, and the running production instance kept serving code from before either merge, for hours, with no error visible anywhere in this repo.
The fix was two-layered: pin the npm major version in both package.json (engines/packageManager) and the Dockerfile, so dev and prod always install with the same npm major; and add a pre-merge check that reproduces the production install from a clean copy of just the manifest and lockfile, so a mismatch is caught before merge instead of discovered after a silent bad deploy. We also added a build.commit field to our health endpoint specifically so "did the new code actually deploy" is a one-line check instead of a guess.
6. Crawler bots that pay the cheapest route per seller
Not every on-chain payment your seller wallet receives is a human evaluating your product. We traced our first confirmed third-party USDC receipt on-chain to its source wallet and found a very different pattern than "one buyer, one purchase": roughly 50 tiny USDC transfers to roughly 50 distinct seller addresses within about two minutes, each one an x402 exact-scheme transferWithAuthorization, amounts clustering at round values ($0.003, $0.005, $0.006, $0.01, $0.02, $0.05). At our own seller, it paid exactly our cheapest listed route.
That shape — one wallet, dozens of unrelated sellers, seconds apart, always the lowest price at each one — reads as an automated sampler or discovery-verification bot working through a catalog, not a buyer comparison-shopping your product. It's genuinely useful signal (it confirms your listing is reachable and your settlement path works end-to-end), but it is not a conversion, and treating it as one will make your funnel numbers lie to you. If you're instrumenting analytics on a seller like this, keep "a settlement happened" and "a buyer chose this over alternatives" as two different claims — don't collapse them.
FAQ
Does x402 refund a buyer automatically if a paid call fails?
No — x402 has no built-in refund mechanism. The only defense is never settling a payment until the handler has actually succeeded, which is a server-side implementation discipline, not something the protocol enforces for you.
How do I know if my x402 challenge is actually spec-valid?
Validate it against the official schema your buyers' clients use — for the v2 protocol, that's PaymentRequiredSchema from @x402/core's published schemas — rather than only checking your own hand-rolled field list. A field can look fine by eye and still fail strict parsing (as our 6-tag case shows).
Do I need to pay to test that my own routes are indexed?
To trigger CDP Bazaar's per-route indexing, yes — at least one real settled call per route, based on our own observation of its behavior. Free discovery-search checks and unpaid 402 probes don't trigger it.
Who's behind this
We're draconic21 — we sell live, pay-per-call data APIs (SEC EDGAR, OFAC sanctions, U.S. Treasury data, and more) over x402 and MCP, plus developer kits for building your own x402/MCP sellers.