Your Agent Shouldn't Have to Guess the Price — One Catalog, Declared on Every 402
AgentBadge now serves a machine-readable paid-services catalog at GET /api/v1/services, and every 402 response across the platform carries an x402 bazaar extension — price, input schema, and free alternatives declared before a single cent moves. One SKU registry feeds the catalog, the wire declarations, and llms.txt; an explicit gate-coverage e2e test fails CI on any new paid route without a SKU.
GET https://agentbadge.xyz/api/v1/services returns the paid surface as a SKU registry: price_usd, endpoint, and input_schema per entry plus a free[] section with next_call pointers. Every 402 carries a bazaar extension whose inputSchema is byte-equal to the catalog's — one function, zero drift.
An agent that wants to buy from an API answers three questions before it pays: what does it cost, what do I send, and is there a free way to try it first? Most payment-gated APIs make the agent guess all three — the price lives in a marketing page, the input schema lives in a wiki, and "free tier" is a sales call. This week AgentBadge closed that loop: GET /api/v1/services returns the full machine-readable catalog, and every HTTP 402 response across the platform declares itself for x402 indexers — price, input schema, and free alternatives, before a single cent moves.
This is article 15 in the Arc Campaign series. It builds directly on C14 (the .well-known discovery surface that helps agents find us) — C15 is what an agent reads once it found us and wants to buy something.

One registry, three consumers
The interesting decision wasn't the endpoint — it was what feeds it. We didn't want another hand-maintained list that rots. So the catalog is a declarative SKU registry (lib/service-catalog) that reads from the same config sources the payment middleware reads from — the scan-bundle price you see in the catalog is the price the gate will actually charge, because both derive from one source of truth.
That one registry now feeds three consumers: GET /api/v1/services — the JSON catalog for agents and indexers; the bazaar extension on every 402 — x402 v2 discovery metadata; and llms.txt — the "Paid Services" section links anchor to /api/v1/services#<sku_id> (from our EPIC-178 discovery work).
Fourteen SKUs across eight paid surfaces today: readiness scans, passport mints, marketplace buys, keeperhub premium scans, eval-as-a-service verdicts/jobs/subscriptions, bstock service passes, and venue instance subscriptions. A SKU looks like this:
{
"sku_id": "eaas:verdict",
"surface": "eaas",
"name": "Eval verdict",
"price_usd": "0.10",
"endpoint": { "method": "POST", "path": "/api/eaas/verdicts" },
"auth": "x402",
"input_schema": {
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"]
}
}
sku_id is surface:slug and immutable once published — it is the public contract agents bookmark and indexers key on. Our coverage test keeps a golden list of all fourteen ids; renaming one fails CI.

![Catalog fragment: services[] with sku_id, endpoint, price_usd and free[] section](/images/blog/arc-c15-x402-bazaar-1.png)
Every 402 declares itself (bazaar)
An x402 "bazaar" extension is metadata inside the 402 response that tells indexers — and paying clients — what the call costs and what the input should look like. The rule we shipped: every 402 in the platform carries it, not just the happy-path REST endpoints.
Every payment gate — x402 Hedera, MPP/Stripe, bstock freemium, the manual settle seam behind venue subscriptions, all three EaaS middlewares — now attaches bazaarExtensionFor("<sku_id>") to its payment options. The extension object looks like:
{
"bazaar": {
"info": {
"input": { "type": "http", "bodyType": "json",
"body": { "url": "https://example.com" } },
"output": { "type": "json", "example": { "score": 72 } }
},
"schema": {
"properties": { "input": { "properties": {
"body": { "type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"] } } } }
}
}
}
The important invariant: schema.properties.input.properties.body is inputSchemaOf(sku) — byte-equal to the schema in the catalog. Nobody hand-copies a schema into a 402; there is exactly one function that produces it, so catalog and wire declaration cannot drift (we wrote that down as decision D-179-4).
Even our L402 (Lightning-style macaroon) gate is accounted for — its challenge is WWW-Authenticate-based with no JSON slot, so it lives on an explicit exception list in the coverage test rather than pretending to carry metadata it can't hold.

Free as the front door
A catalog that only lists prices is half a catalog. Agents evaluating a new provider want to try before they trust, so /api/v1/services carries a free[] section alongside services[] — health checks, the scan-packs catalog (GET /api/scan-packs), marketplace browsing — each with a next_call pointer to the natural paid follow-up.
That made the free section an onboarding bridge rather than a footnote: an agent can hit /api/health and next_call points it at the free scan-packs listing; the scan-packs listing explains which paid bundles exist; the paid bundle declares its bazaar schema on the 402. Discovery → free trial → paid call, with zero documentation reading.

![free[] as the onboarding bridge into paid SKUs](/images/blog/arc-c15-x402-bazaar-4.png)
No paid endpoint without a declaration
A registry only stays honest if adding a route without registering it breaks the build. tests/e2e/catalog-coverage.test.ts keeps an explicit GATE_TABLE — every payment gate in the codebase, mapped to the SKU ids that cover its endpoint. The test walks both directions: every gate row must resolve to real SKUs on that endpoint, and every SKU's endpoint must be claimed by a gate row. Then it mounts the real route modules and asserts each SKU endpoint resolves (a Hono bare-404 fails; a handler-level "unknown id" 404 doesn't — we learned that one the hard way with fixture :param values).
Two rows aren't SKUs by design: l402 (macaroon challenges, no JSON slot) and attestation-api (internal trust surface, not a product). They sit in GATE_EXCEPTIONS with written reasons — an allowlist that can only grow in code review, never silently.

Honest status
Shipped across five slices: the SKU registry, GET /api/v1/services, bazaar coverage on every 402 (including the settle-seam PAYMENT-REQUIRED header — that slot was empty before), the free[] section with next_call bridges into llms.txt, and the coverage/drift e2e contract. Deprecated /pricing.json and /api/meta/fees still serve but point at /api/v1/services — additive, no breaking change for existing clients.
What's not here yet: tenant-published market services don't appear in the catalog (they live under /api/market/services and are dynamic — that's O3 on our decisions doc, deliberately out of scope), and the catalog advertises USD prices while the runtime router decides which chains/assets to accept — separation of declaration and settlement is intentional.
Try it
# The whole paid surface, one GET:
curl -s https://agentbadge.xyz/api/v1/services | jq '.services[].sku_id'
# Decode a real 402's bazaar declaration:
curl -s -X POST https://agentbadge.xyz/api/total-scan \
-H 'content-type: application/json' -d '{}' \
-D - | grep -i payment-required | cut -d' ' -f2- \
| base64 -d | jq '.extensions.bazaar.info'
- Catalog implementation:
src/server/routes/services-catalog.tsandlib/service-catalog/in the agentbadge repo - Coverage contract:
tests/e2e/catalog-coverage.test.ts— the GATE_TABLE every new paid route must join
This is C15 in the Arc Campaign series. Earlier: C14 built the .well-known surface that gets agents to the door; C15 is what they read at the counter. Next in the pipeline: verdict transparency and keyless signers.