back to articles

published 2026-05-13

x402station.io is infrastructure for x402 agentic commerce. Our active wedge is Preflight by x402station.io: agents call it before signing PAYMENT-SIGNATURE for an unfamiliar x402 endpoint.

This week I learned that my mental model of Coinbase x402 Bazaar discovery was wrong.

I thought Bazaar behaved like a normal agent metadata crawler:

  1. Serve a good /.well-known/x402.
  2. Keep OpenAPI and the agent card current.
  3. Wait for the catalog to crawl and refresh.

That is useful for normal web discovery. It was not the behavior that mattered for Bazaar route metadata.

The operational model that matched production was different:

Bazaar discovery is settlement-pull. A paid route becomes visible or refreshes when a payment settles through the facilitator, and the metadata comes from the route configuration that participates in that x402 handshake.

That distinction sounds small until a bad route template lands in discovery.

TL;DR

The myth: "Bazaar crawls my manifest"

Our first pass treated Bazaar like an agent crawler.

We had already done the normal metadata work:

The source looked correct. The manifest looked correct. The route files even contained explicit Bazaar routeTemplate fields.

But public discovery state did not move the way a crawler model predicts. Some routes were listed. Some were missing. Existing listings stayed stale. The missing routes had a pattern: they had not settled through the facilitator since the metadata changed.

That was the turn.

The reality: settlement refreshes the listing

The model that matched what we saw was:

  1. Client calls a paid route.
  2. Server returns 402 Payment Required with a payment-required header.
  3. Client signs and retries with PAYMENT-SIGNATURE.
  4. Facilitator verifies and settles.
  5. Bazaar receives route declaration metadata attached to that x402 flow.
  6. Discovery refreshes after the settlement path has processed.

For an operator, the practical consequence is simple:

If you change Bazaar-facing metadata, plan one clean settlement per paid route you want refreshed.

Do not spend money blindly. First decode the unsigned 402 challenge and verify the metadata the facilitator will see.

That sentence would have saved a loop.

The bug: why our routes showed up as :var1

We had added explicit literal route templates to the paid route configs:

extensions: {
  bazaar: {
    ...declareDiscoveryExtension({
      bodyType: "json",
      input: { url: "https://api.venice.ai/api/v1/chat/completions" },
      inputSchema: {
        type: "object",
        required: ["url"],
        properties: { url: { type: "string", format: "uri" } },
      },
      output: { example: { ok: false, warnings: ["zombie"] } },
    }).bazaar,
    routeTemplate: "/api/v1/preflight",
  },
}

The source looked correct.

Then we decoded the live 402 challenge. It still said:

{
  "extensions": {
    "bazaar": {
      "routeTemplate": ":var1"
    }
  }
}

That was the important failure mode: the code contained the right field, but the runtime artifact still carried the wrong value.

The root cause was lower in the helper stack. The convenient withX402(handler, routeConfig, server) path in @x402/next effectively builds a one-route HTTP resource server with a wildcard key:

const routes = { "*": routeConfig };
const httpServer = new x402HTTPResourceServer(server, routes);
return withX402FromHTTPServer(routeHandler, httpServer);

The route pattern entering Bazaar enrichment was not the Next.js file path. It was "*".

That wildcard was normalized into a colon parameter. The Bazaar declaration ended up with :var1.

The tempting fix I did not take

The first architectural proposal was to switch to a proxy-style API that accepts a map of literal paths.

That sounds right if the only problem is "I need literal route keys." It was not the right abstraction for our paid API.

For real-money x402 routes, one contract matters a lot:

Settlement should happen only after the route handler has successfully produced the paid response.

That is the behavior we wanted from the route-level flow. The handler can validate input, run bounded database work, and return a failure before funds settle if the service cannot safely fulfill.

A broad proxy or middleware shape can change where settlement sits relative to the route handler. That might be fine for static resources or simple proxies. It was not a safe default for routes that create state:

So the fix had to preserve route-level settlement semantics while giving Bazaar a literal path.

The production-safe fix

The API we needed was already public. We just had to use one layer lower.

import type { NextRequest, NextResponse } from "next/server";
import {
  withX402FromHTTPServer,
  x402HTTPResourceServer,
  type RouteConfig,
} from "@x402/next";

import { resourceServer } from "@/lib/x402-server";

type RouteHandler<T = unknown> = (request: NextRequest) => Promise<NextResponse<T>>;

export function withX402Route<T = unknown>(
  routeHandler: RouteHandler<T>,
  routePath: `/${string}`,
  routeConfig: RouteConfig,
) {
  const httpServer = new x402HTTPResourceServer(resourceServer, {
    [routePath]: routeConfig,
  });

  return withX402FromHTTPServer(routeHandler, httpServer);
}

Then each paid route moved from:

export const POST = withPublicUrl(
  withX402(handler, routeConfig, resourceServer),
);

to:

export const POST = withPublicUrl(
  withX402Route(handler, "/api/v1/preflight", routeConfig),
);

This does two things:

  1. The x402 flow still uses withX402FromHTTPServer, the same lower-level engine used by the convenience helper.
  2. The resource server route map now contains the literal public path instead of "*".

After that change, decoding the unsigned 402 challenge showed the acceptance criterion we actually cared about:

{
  "resource": {
    "url": "https://x402station.io/api/v1/preflight",
    "description": "x402station.io Preflight ..."
  },
  "extensions": {
    "bazaar": {
      "routeTemplate": "/api/v1/preflight"
    }
  }
}

The verification script pattern

We added an operator script so this does not depend on memory next time.

The dry-run path makes one unsigned POST to each paid route and decodes the payment-required header:

bun run scripts/bazaar-bootstrap.ts --prod

For each route it checks:

Only after that dry-run passes can the script spend money:

bun run scripts/bazaar-bootstrap.ts --prod --execute --yes \
  --only preflight,preflight-batch,forensics,catalog-decoys,whats-new,alternatives,credits,watch \
  --max-usdc 0.60

That command is intentionally verbose. Real money should have ceremony.

The script also computes the total before payment. This caught another mistaken assumption: refreshing all nine routes was not a one-cent operation.

Route Cost
preflight $0.001
preflight-batch $0.025
forensics $0.001
catalog-decoys $0.005
whats-new $0.001
alternatives $0.005
credits $0.50
watch $0.01
verified $1.00
Total $1.548

That changed the refresh strategy. The sensible first pass was to refresh the routes that were missing or cheap enough to justify immediately, and let the $1.00 verified route refresh on the next organic provider mint.

What happened after settlement

Pre-settlement state:

Route Bazaar state before refresh
preflight listed, stale template
preflight-batch missing
forensics listed, stale template
catalog-decoys listed, stale template
whats-new listed, stale template
alternatives listed, stale template
credits missing
watch missing
verified listed, stale template

Path B refreshed eight routes:

preflight
preflight-batch
forensics
catalog-decoys
whats-new
alternatives
credits
watch

Expected spend was $0.548 USDC. Observed recent settlement transfers from the test wallet totaled $0.546 across 13 transactions because two close settlement batches happened during coordination and one route failed before settlement.

Useful net result:

preflight-batch needed a separate fix. The first implementation held a dedicated Timescale aggregate query over recent probe history and timed out at roughly 700 ms. The second implementation reused the same single-URL preflight core per deduped target with bounded concurrency three. After that change, a paid retry returned HTTP 200 in about 1.85 seconds and settled $0.025 on Base mainnet.

After the indexing window, the CDP discovery API showed all nine x402station.io paid routes with literal route templates:

Resource Bazaar routeTemplate
/api/v1/preflight /api/v1/preflight
/api/v1/preflight-batch /api/v1/preflight-batch
/api/v1/forensics /api/v1/forensics
/api/v1/catalog/decoys /api/v1/catalog/decoys
/api/v1/whats-new /api/v1/whats-new
/api/v1/alternatives /api/v1/alternatives
/api/v1/credits /api/v1/credits
/api/v1/watch /api/v1/watch
/api/v1/verified /api/v1/verified

That is a distribution and metadata result, not demand. We do not count operator test settlements as usage traction.

The 12-hour observable

Within roughly twelve hours of the Path B refresh, a wallet we had not seen before paid $1.00 USDC to mint a verified-badge on https://api.venice.ai/api/v1/chat/completions, the same endpoint we cite as a canonical zombie example in paid-route 402 challenge bodies.

The wallet also ran four cheap evaluation calls in the same minute: /forensics, /whats-new, /catalog/decoys, and /alternatives.

The mint result was not_verified. The endpoint failed the audit. The verified-flow returned the same answer the public signal already implied.

For this article, the point is narrow: clean settlement-pull metadata made the product surface discoverable enough for a third party to traverse the paid evaluation path. It is not a claim that our operator refresh spend was customer demand. It was distribution plumbing.

Checklist for x402 operators

If you publish x402 routes and care about Bazaar discovery, this is the checklist I wish I had started with.

1. Decode the 402 challenge

Do not inspect only source code or manifests. Hit the route without payment and decode the header:

const response = await fetch("https://your-service.example/api/paid-route", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ example: true }),
});

const encoded = response.headers.get("payment-required");
const decoded = JSON.parse(Buffer.from(encoded!, "base64url").toString("utf8"));
console.log(decoded.extensions?.bazaar?.routeTemplate);

If the decoded artifact is wrong, do not settle. Settlement will only index the wrong thing faster.

2. Check your wrapper abstraction

If you use @x402/next and your route template becomes :var1, inspect whether your wrapper registered the route as "*".

For a route-level handler where fulfillment must happen before settlement, prefer a literal route map with withX402FromHTTPServer:

const httpServer = new x402HTTPResourceServer(resourceServer, {
  "/api/v1/preflight": routeConfig,
});

export const POST = withX402FromHTTPServer(handler, httpServer);

Wrap that in a helper if you have multiple routes.

3. Preserve settlement semantics

Do not move to a broader proxy/middleware integration just because it has a nicer route map. First ask where settlement happens relative to:

If a route creates state after payment, this ordering matters.

4. Budget your refresh

Settlement-pull discovery means every refresh has a route-price cost.

If one route costs $1.00 and another costs $0.001, they should not be treated the same operationally. Refresh missing or distribution-critical routes first. Let expensive routes refresh on organic usage when that is acceptable.

5. Re-audit after the refresh window

After settlement, wait for the discovery pipeline to catch up and query the public discovery API again:

curl -s 'https://api.cdp.coinbase.com/platform/v2/x402/discovery/resources?limit=500'

Then compare:

Why this matters for agents

This is not just a catalog nicety.

Agents are increasingly using discovery surfaces as ranking inputs. A route with a clean name, useful description, recent settlement, and accurate template is easier for an agent to select and call correctly.

Bad metadata has a real cost:

For x402station.io, Bazaar metadata is directly tied to the job of Preflight by x402station.io: help agents decide what to pay before they sign. If the discovery layer cannot accurately represent the safety layer, the funnel is weaker.

Related artifacts

If you are building an agent that pays x402 endpoints, wire a pre-payment check before the retry that carries PAYMENT-SIGNATURE.

If you are publishing x402 endpoints, decode your own 402 challenge before you trust your discovery metadata.

back to articles5 total · published 2026-05-13