DEV Community

Cover image for Shipping a $0.008-per-call AML API on x402 (and the CDP Bazaar bug we hit on launch day)
Kenzo ARAI
Kenzo ARAI

Posted on

Shipping a $0.008-per-call AML API on x402 (and the CDP Bazaar bug we hit on launch day)

TL;DR — An AI agent paid us $0.008 USDC on Base mainnet to scan a Tornado Cash address. The agent got back risk_level: CRITICAL, detection_count: 64, ml_anomaly_score: 0.4381 in about 30 seconds. No signup, no API key, no Stripe — the agent signed an EIP-3009 transferWithAuthorization, our middleware verified + settled it via the Coinbase CDP Facilitator, and the bytes flowed. Three settlements have landed since 2026-04-28. The intended next step was Coinbase Bazaar auto-indexing on agentic.market. That hasn't happened — we eventually narrowed it to a CDP-side pipeline bug (the documented EXTENSION-RESPONSES header is missing entirely on every settle, matching three other teams' independent reproductions in x402-foundation/x402#2112, #2156, #2132). We're now listed on x402scan as the alternative discovery path. Below: what x402 is, the five things we fixed on our side, the one thing CDP needs to fix on theirs, and how to call the API yourself.


Why x402 matters (in one paragraph)

The dirty secret of "AI agents using APIs" is that the API still wants a human-issued bearer token. An agent that wants new data has to either reuse one of the developer's keys (a security disaster) or stop and ask. x402 flips this: the API replies HTTP 402 Payment Required with a price, the agent signs a stablecoin transfer for that exact price, replays the request, and gets the response. No accounts. No keys. The economic unit is per-call, not per-month. For an AML / risk-scoring API like ours, this is exactly the right shape — agents pay only for the addresses they actually scan, and we don't have to gate access behind sign-ups.

Coinbase's CDP x402 facilitator does the heavy lifting (verifying signatures, broadcasting USDC transfers on Base / Solana mainnet). The promise is real. The middle, where the implementation lives, is where everything broke — first on our side (fixable), then on CDP's side (still open).


Five things that broke on our side

1. Our _verify_payment was sending the wrong wire format entirely

We had a pre-existing implementation that sent the facilitator something like:

{ "payment": "<base64>", "requirements": { "scheme": "exact", "price": "$0.008" } }
Enter fullscreen mode Exit fullscreen mode

That is nothing like the x402 v2 spec. The CDP facilitator expects a fully-typed paymentPayload (pydantic v2 schema with accepted + payload fields) and a paymentRequirements block that names the network, asset, payTo, atomic amount, and EIP-712 metadata. Verification was failing silently, and we'd been seeing HTTP 402: payment may have failed verification for weeks without realizing the request shape itself was wrong.

Fix: Stop hand-rolling. Add x402[httpx]>=2.9.0 to requirements.txt and let the official library own the wire format:

from x402.client.facilitator.https import HTTPFacilitatorClient
from x402.types import PaymentPayload, PaymentRequirements

facilitator = HTTPFacilitatorClient(base_url=FACILITATOR_URL, auth_provider=auth)
verify_resp = await facilitator.verify(payload, requirements)
Enter fullscreen mode Exit fullscreen mode

The shape the facilitator actually wants:

{
  "x402Version": 2,
  "paymentPayload": {
    "x402Version": 2,
    "payload": { "signature": "0x...", "authorization": { "...": "..." } },
    "accepted": {
      "scheme": "exact",
      "network": "eip155:8453",
      "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
      "amount": "8000",
      "payTo": "0xe8e26183...708959",
      "maxTimeoutSeconds": 60,
      "extra": { "name": "USD Coin", "version": "2" }
    },
    "resource": { "url": "...", "description": "...", "mimeType": "application/json" }
  },
  "paymentRequirements": {
    "scheme": "exact",
    "network": "eip155:8453",
    "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
    "amount": "8000",
    "payTo": "0xe8e26183...708959",
    "maxTimeoutSeconds": 60
  }
}
Enter fullscreen mode Exit fullscreen mode

2. There was no _settle_payment call at all

Even if /verify had ever passed, no USDC would have moved. The middleware had no /settle step. The contract for x402 is verify → handler → settle, and we were stopping at verify.

Fix: Wire the full middleware:

async def x402_middleware(request, call_next):
    payload = _decode_payment_payload(request.headers["X-PAYMENT"])
    requirements = _requirements_for_payload(payload, accepts)

    verify_resp = await facilitator.verify(payload, requirements)
    if not verify_resp.is_valid:
        return _402(reason=verify_resp.invalid_reason)

    response = await call_next(request)

    # Only settle if the handler succeeded — failed scans don't get charged
    if 200 <= response.status_code < 300:
        settle_resp = await facilitator.settle(payload, requirements)
        if settle_resp.success:
            response.headers["X-PAYMENT-RESPONSE"] = b64_encode(settle_resp.json())

    return response
Enter fullscreen mode Exit fullscreen mode

The "only settle on 2xx" rule is important: if the AML scan crashes mid-flight, the agent shouldn't be charged. Conversely, once the data is over the wire, there is no rollback — settle is the commit.

3. CDP credentials were never injected into the container

The Azure secrets cdp-api-key-id and cdp-api-key-secret had been provisioned weeks ago. They just weren't being passed as env vars to the running container. So _generate_cdp_jwt() was returning None silently, the facilitator client was sending unauthenticated requests, and CDP was rejecting them.

Fix: Two-line patch to the deploy script:

# scripts/azure-deploy.sh
az containerapp update ... \
  --set-env-vars \
    ... \
    CDP_API_KEY_ID=secretref:cdp-api-key-id \
    CDP_API_KEY_SECRET=secretref:cdp-api-key-secret
Enter fullscreen mode Exit fullscreen mode

Generic lesson: provisioning a secret in Key Vault and referencing it from the container app are two different deploys. Check both.

4. The SvelteKit catch-all proxy was stripping X-PAYMENT

We have a generic /api/v1/[...path]/+server.ts that proxies the SvelteKit edge to the Python backend. It was forwarding X-API-Key and Content-Type and nothing else. The browser-side x402 demo would sign correctly, attach X-PAYMENT, and watch it disappear by the time the request hit Python.

Fix: Forward x402 protocol headers explicitly, plus CORS preflight allow-list:

const FORWARD_HEADERS = ["x-api-key", "x-payment", "payment-signature", "content-type"]

// On the OPTIONS preflight:
"Access-Control-Allow-Headers": "X-API-Key, X-Payment, Payment-Signature, Content-Type"
Enter fullscreen mode Exit fullscreen mode

We also moved the demo to the dedicated /x402/api/... route which has x402-aware proxy logic, leaving the generic catch-all for plain REST traffic.

5. Our demo wallet self-transferred to the treasury

The browser demo connected Phantom and signed a transferWithAuthorization from the connected account to the treasury payTo — except the connected account was the treasury. EIP-3009 has no problem with that (you can sign a transfer from yourself to yourself), but the facilitator's verifier rejected it because the signed from matched the platform's payTo, which it correctly read as a self-payment loop, not a real customer payment.

Fix: Cheap client-side guard:

if (account.toLowerCase() === acceptEntry.payTo.toLowerCase()) {
  throw new Error(
    "Connected wallet is the treasury — switch to a different account to test."
  )
}
Enter fullscreen mode Exit fullscreen mode

Once we connected from a fresh wallet (a separate Phantom account funded with $0.50 of USDC), the signature went through. The buyer's wallet, the treasury, and the facilitator are all different parties — they have to be.


What a successful settlement actually looks like

After all of the above, the production curl is anticlimactic:

curl -i \
  -H "X-PAYMENT: $(./build_payment.ts $WALLET 0.008 USDC base)" \
  https://chain-analyzer.com/x402/api/address/0x0000db5c8b030ae20308ac975898e09741e70000/risk-score?chain=ethereum

# HTTP/2 200
# X-PAYMENT-RESPONSE: eyJzdWNjZXNzIjp0cnVlLCJ0eEhhc2giOiIweGFiYy4uLiJ9
# Content-Type: application/json
#
# {
#   "address": "0x0000db5c8b030ae20308ac975898e09741e70000",
#   "chain": "ethereum",
#   "risk_level": "CRITICAL",
#   "risk_score": 95,
#   "detection_count": 64,
#   "ml_anomaly_score": 0.4381,
#   "detections": [
#     { "id": "OFAC_SANCTIONED", "severity": "CRITICAL", ... },
#     { "id": "TORNADO_CASH_INTERACTION", "severity": "CRITICAL", ... },
#     { "id": "ADDRESS_POISONING_RECEIVED", "severity": "HIGH", ... },
#     ...
#   ]
# }
Enter fullscreen mode Exit fullscreen mode

X-PAYMENT-RESPONSE is the base64-encoded settle receipt — the agent can decode it to get the on-chain tx hash and confirm the USDC really moved on Base. That's the loop closed: agent paid, scan ran, agent got data, settlement is on-chain. Three of these have landed for us across 2026-04-28 / 2026-04-29.


What's still broken on CDP's side: Bazaar agentic.market indexing

The plan was simple: a successful settlement on a route that carries extensions.bazaar.discoverable=true triggers Coinbase's Bazaar pipeline to crawl the manifest and add the resource to agentic.market. We shipped the metadata, settled the payments, and waited.

It didn't happen. After three settlements over 36 hours, the resource is still not in the Bazaar discovery index, and /discovery/merchant?payTo=... returns 404.

The diagnostic signal in the Bazaar Indexing Process docs is the EXTENSION-RESPONSES header on the facilitator's /verify and /settle responses:

Header value Meaning
processing Bazaar accepted the metadata, async indexing in flight
rejected Bazaar rejected (schema / mimeType / etc.)
missing Bazaar pipeline never received the metadata (CDP-side issue)

To see which case applies to us we patched httpx.AsyncClient.send inside the FastAPI process to log every facilitator response header before any middleware touched it. The header is absent in every casing on every settlement we've made. Not processing, not rejected, just nothing.

This puts us in the same spot as three other teams that filed reproductions:

  • x402-foundation/x402#2112siggy (rtkmotion.io) captured the same absence at the Cloudflare Worker level on Base mainnet, across nine settlements
  • Same thread — srotzin (HiveCompute) reproduced it on Solana / USDT
  • #2156 — third independent stack, agentic.market still empty after 8 settlements
  • #2132 — our own ecosystem-listing request, also pending

Across the four reproductions, the only commonality is that every payTo address is a non-CDP-registered EOA (we generated ours outside the CDP wallet system). The current strongest hypothesis — not yet confirmed by Coinbase — is that the Bazaar pipeline is gated on payee wallets being CDP-account-linked, and the missing header is a symptom of that gating happening silently. If true, this is a one-line documentation fix on CDP's side that would have saved every team filing into those issues a week of metadata iteration.

There's nothing we can change on our end to make Bazaar pick us up today. The metadata is correct (it matches Sentinel AML's catalogued listing structure), the settlements are real, the manifest is live. We're waiting on CDP.


Listed on x402scan instead

Bazaar isn't the only x402 discovery layer. x402scan (run by Merit-Systems) is an independent ecosystem explorer that crawls services via /.well-known/x402. Submitting our discovery doc registered five GET endpoints with full activity tracking inside ~10 seconds:

www.x402scan.com/server/78ee8ac2-bc8c-4d44-a565-45ead6dd5364

Getting through their probe required two extra fixes that the CDP-only path didn't surface:

  • Emit a base64 Payment-Required header alongside the JSON body. The validator (@agentcash/discovery v1.6.x) reads the v2 challenge from a header — base64-decoding atob(headerValue) to get the payload — and falls back only to a v1 JSON body parser if absent. Our v2 JSON body wasn't enough on its own. We now emit both: the rich body for CDP / x402-fetch and a flattened Coinbase-schema header for any v2 strict validator.
  • Add a bazaar.schema.properties.input.properties.{body|queryParams} block. agentcash's extractSchemas2 only recognises body or queryParams for input — it does not look at pathParams, which is where our existing bazaar.info block had the field definitions. We now synthesize a parallel schema block at request time so the same field names show up under both keys.

With those in place, x402scan picked up all five GET routes (the POST-only batch/screening is still excluded — their probe is GET-only, so a 405 from a POST endpoint counts as failed registration). Our build notes from this debug session are in the @agentcash/discovery issue tracker for anyone hitting the same wall.


Try it yourself

There are three reasonable on-ramps depending on what you're holding:

a) curl from a TypeScript helper

If you have a wallet on Base with a few cents of USDC:

import { createWalletClient, custom } from "viem"
import { base } from "viem/chains"

// Browser: connect Phantom (or any EIP-1193 wallet) to Base mainnet
const wallet = createWalletClient({
  chain: base,
  transport: custom(window.phantom.ethereum),
})
const [account] = await wallet.requestAddresses()

// Sign EIP-3009 transferWithAuthorization for $0.008 USDC, replay
// the request with X-PAYMENT — middleware verifies + settles via the
// Coinbase CDP facilitator, then returns the risk-score JSON
const res = await fetch(
  "https://chain-analyzer.com/x402/api/address/" +
    "0x0000db5c8b030ae20308ac975898e09741e70000/risk-score?chain=ethereum",
  { headers: { "X-PAYMENT": buildPayment(account, /* ... */) } },
)
const score = await res.json()
// score.risk_level === "CRITICAL", score.detection_count === 64
Enter fullscreen mode Exit fullscreen mode

b) The browser demo

chain-analyzer.com/x402-demo.html connects Phantom on Base, lets you punch in any address, and runs the full pay → scan → settle flow with a visible tx hash at the end. Costs you $0.008 + Base gas (a few cents).

c) Through an MCP-aware AI agent

If you're using Claude or any MCP client, our chainanalyzer-mcp package exposes the same endpoints as MCP tools. The agent handles the x402 dance for you — you just say "scan this address" in natural language.


The endpoint menu

Method Endpoint Use case Price
GET /x402/api/address/{addr}/risk-score 0–100 risk score + detection list $0.008 USDC
GET /x402/api/address/{addr}/sanctions OFAC / FATF / JFSA sanction list match $0.003 USDC
GET /x402/api/tx/{tx_hash}/trace Multi-hop fund flow trace $0.015 USDC
GET /x402/api/tx/{tx_hash}/coinjoin CoinJoin / mixer detection $0.01 USDC
GET /x402/api/wallet/{addr}/cluster Common-spend wallet clustering $0.02 USDC
POST /x402/api/batch/screening Batch address screening (≤50 per call) $0.05 USDC

Chain coverage: 8 chains live for self-serve via the ?chain= query parameter — bitcoin, ethereum, polygon, base, arbitrum, optimism, avalanche, solana. BNB Smart Chain is on Enterprise rollout (paid Etherscan tier required) and is reachable through the subscription API key path, not via x402.

The full advertised manifest is at chain-analyzer.com/.well-known/x402 — that's the file the x402scan crawler reads, and the same one CDP's Bazaar crawler is supposed to read once the upstream pipeline is fixed.


The bigger picture

We didn't build x402 support to chase a buzzword. We built it because AI agents are the natural buyers of AML data — they don't have personal addresses, they don't sign up for SaaS plans, they need to know in 30 seconds whether the wallet on the other side of a transaction is a Tornado Cash router or a normie. Sign-up forms and quarterly contracts don't fit that workload. Pay-per-call USDC does.

The settlement layer works. The Coinbase Bazaar discovery layer is, today, blocked on a CDP-side bug we can't reach into. The x402scan discovery layer works fine. If you're a team about to ship x402 with a non-CDP-registered payee EOA, you'll likely hit the same Bazaar wall — file into #2112 so the signal reaches Coinbase, and submit your service to x402scan in parallel so your endpoints are reachable in the meantime.

The per-call cost of plugging an autonomous agent into multi-chain AML data is now half a US cent. That's where the agent economy is going, and we'd rather be on the supply side.


Links


This article was written by Kenzo ARAI (refinancier, inc.). Build notes published openly so other teams shipping x402 can skip the same five potholes — and route around the sixth.

Top comments (0)