> x402 Implementation Guide — Building Payment-Gated APIs
x402 Implementation Guide — Building Payment-Gated APIs
Everything learned building Rug Scanner — a production x402 API with Hono, the Coinbase CDP facilitator, and EIP-3009 payments on Base. From zero to settled transactions.
Architecture overview
Agent → POST /scan (no payment header)
← 402 + PAYMENT-REQUIRED header (base64 JSON, v2)
Agent → POST /scan + PAYMENT-SIGNATURE header (signed EIP-3009)
→ Server extracts payment, calls CDP /verify (JWT auth)
→ CDP simulates transferWithAuthorization on-chain
← Verify OK → run handler → call CDP /settle
← 200 + scan result + PAYMENT-RESPONSE header (tx hash)
Tech stack (proven working)
| Layer | Package | Version | Notes |
|-------|---------|---------|-------|
| Server framework | hono | ^4.6.0 | Any framework works; adapters exist for Express, Fastify |
| x402 middleware | @x402/hono | 2.9.0 | Wraps Hono middleware, handles 402 / verify / settle |
| Payment scheme | @x402/evm/exact/server | 2.9.0 | Server-side ExactEvmScheme (EIP-3009) |
| Facilitator client | @x402/core/server | 2.9.0 | HTTPFacilitatorClient for CDP |
| CDP JWT auth | @coinbase/cdp-sdk/auth | ^1.46.1 | generateJwt — handles Ed25519 + EC keys |
| Client SDK | @x402/fetch | 2.9.0 | wrapFetchWithPayment — drop-in fetch wrapper |
| Client scheme | @x402/evm | 2.9.0 | Client-side ExactEvmScheme + toClientEvmSigner |
| Wallet / signing | viem | ^2.21.0 | privateKeyToAccount, createPublicClient |
Critical: keep all @x402/* packages on the same minor version. They share @x402/core and version mismatches cause subtle failures.
Server implementation
1. CDP auth headers
The CDP facilitator requires JWT auth per request. Each JWT is scoped to a specific HTTP method + path:
import { generateJwt } from '@coinbase/cdp-sdk/auth';
async function createCdpAuthHeaders(apiKeyId: string, apiKeySecret: string) {
const makeHeaders = async (method: string, path: string) => {
const token = await generateJwt({
apiKeyId,
apiKeySecret,
requestMethod: method,
requestHost: 'api.cdp.coinbase.com',
requestPath: path,
expiresIn: 120,
});
return { Authorization: `Bearer ${token}` };
};
return {
verify: await makeHeaders('POST', '/platform/v2/x402/verify'),
settle: await makeHeaders('POST', '/platform/v2/x402/settle'),
supported: await makeHeaders('GET', '/platform/v2/x402/supported'),
};
}
Key details:
requestHostmust be'api.cdp.coinbase.com'(no protocol prefix).requestPathmust match the facilitator URL path exactly:/platform/v2/x402/verify.expiresIn: 120= 2 minutes (default); regenerated per facilitator call via callback.- The
createAuthHeaderscallback fires every time the facilitator makes an HTTP request, so JWTs are always fresh. - CDP keys come as
apiKeyId(UUID) +apiKeySecret(base64 Ed25519, 64 bytes decoded). The SDK auto-detects format.
2. Middleware setup
import { paymentMiddleware, x402ResourceServer } from '@x402/hono';
import { HTTPFacilitatorClient } from '@x402/core/server';
import { ExactEvmScheme } from '@x402/evm/exact/server';
const facilitator = new HTTPFacilitatorClient({
url: 'https://api.cdp.coinbase.com/platform/v2/x402',
createAuthHeaders: () => createCdpAuthHeaders(keyId, keySecret),
});
const server = new x402ResourceServer(facilitator)
.register('eip155:8453', new ExactEvmScheme());
const middleware = paymentMiddleware(
{
'POST /scan': {
accepts: {
scheme: 'exact',
payTo: '0xYourWalletAddress',
price: '$0.05',
network: 'eip155:8453',
},
description: 'Token risk analysis',
},
},
server,
);
app.use('/scan', middleware);
3. Cache the middleware (important)
Do NOT create the middleware per-request. The x402ResourceServer calls getSupported() on initialization. Recreating per-request means hitting the CDP API on every request and losing the initialized state between the 402 response and the payment verification.
let cachedMiddleware: MiddlewareHandler | null = null;
export function createX402Middleware(env: Env): MiddlewareHandler {
if (cachedMiddleware) return cachedMiddleware;
// ... create facilitator, server, middleware ...
cachedMiddleware = middleware;
return cachedMiddleware;
}
4. Lifecycle hooks for observability
The x402ResourceServer supports hooks at every stage. Essential for production debugging:
server
.onAfterVerify(async (ctx) => {
if (!ctx.result.isValid) {
console.error('[x402] Verify rejected:', ctx.result.invalidReason);
}
})
.onVerifyFailure(async (ctx) => {
console.error('[x402] Verify FAILED:', ctx.error.message);
})
.onAfterSettle(async (ctx) => {
console.log('[x402] Settled — tx:', ctx.result.transaction);
})
.onSettleFailure(async (ctx) => {
console.error('[x402] Settle FAILED:', ctx.error.message);
});
Hook signatures use a single context object:
BeforeVerifyHook:(ctx: { paymentPayload, requirements }) => Promise<void | { abort, reason }>AfterVerifyHook:(ctx: { paymentPayload, requirements, result }) => Promise<void>OnVerifyFailureHook:(ctx: { paymentPayload, requirements, error }) => Promise<void | { recovered, result }>
5. Discovery file (/.well-known/x402.json)
Required for x402 Bazaar auto-indexing and agent discovery:
{
"x402Version": 2,
"endpoints": [{
"path": "/scan",
"method": "POST",
"description": "On-chain token risk analysis",
"accepts": [{
"scheme": "exact",
"network": "eip155:8453",
"maxAmountRequired": "50000",
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0xYourWallet",
"maxTimeoutSeconds": 300
}],
"requestSchema": { },
"responseSchema": { }
}]
}
maxAmountRequired is in token smallest units (USDC has 6 decimals: $0.05 = 50000).
Client implementation
Paying for an x402 API
import { privateKeyToAccount } from 'viem/accounts';
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';
import { ExactEvmScheme, toClientEvmSigner } from '@x402/evm';
import { x402Client, wrapFetchWithPayment } from '@x402/fetch';
const account = privateKeyToAccount('0x...');
const publicClient = createPublicClient({ chain: base, transport: http() });
const signer = toClientEvmSigner(account, publicClient);
const client = new x402Client();
client.register('eip155:8453', new ExactEvmScheme(signer));
const x402Fetch = wrapFetchWithPayment(fetch, client);
// Use exactly like normal fetch — payment is automatic
const response = await x402Fetch('https://api.example.com/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: '0x...', chain: 'base' }),
});
The wrapFetchWithPayment handles the full cycle:
- Sends the initial request.
- If 402, reads
PAYMENT-REQUIREDheader (v2) or body (v1). - Creates EIP-3009
transferWithAuthorizationsignature. - Resends with
PAYMENT-SIGNATUREheader. - Returns the final response.
Environment variables
# Core API keys
ALCHEMY_API_KEY= # On-chain data (or any RPC provider)
# x402 payment gate
X402_WALLET_ADDRESS= # Receives USDC payments
CDP_API_KEY_ID= # Coinbase Developer Platform UUID
CDP_API_KEY_SECRET= # Base64 Ed25519 key (64 bytes decoded)
Get CDP keys at: https://portal.cdp.coinbase.com/
Debugging playbook
Error: execution reverted
Cause: on-chain simulation of transferWithAuthorization failed.
- Wallet has insufficient USDC balance.
- Wallet hasn't approved the transfer (shouldn't happen with EIP-3009).
Fix: fund the payer wallet with USDC on the correct chain.
Error: invalid_payload
Cause: CDP facilitator rejected the payload structure.
- Most common:
from === to(self-payment). CDP rejects self-transfers. - Malformed EIP-3009 authorization fields.
- Wrong
extra.nameorextra.versionfor the token's EIP-712 domain.
Fix: use a different wallet for paying vs receiving.
Error: 502 from middleware
Cause: facilitator initialization failed (getSupported() call).
- Bad JWT auth (wrong
requestHostorrequestPath). - CDP API key invalid or expired.
Fix: test JWT auth directly: GET /platform/v2/x402/supported with Bearer token.
Second request still gets 402
Cause: payment header not detected or requirements don't match.
- Check
PAYMENT-SIGNATUREheader is present (case-insensitive in Hono). - Requirements mismatch:
deepEqualcomparison fails between the client'sacceptedand the server's freshly generated requirements. - Middleware recreated per-request (loses state) — use the caching pattern above.
Diagnostic scripts
Test facilitator auth directly:
railway run -- npx tsx scripts/test-facilitator.ts
Test full e2e flow:
PRIVATE_KEY=0x... npx tsx scripts/test-e2e.ts http://localhost:3000
CDP facilitator details
| Endpoint | Method | Purpose |
|----------|--------|---------|
| /platform/v2/x402/supported | GET | Returns supported networks, schemes, versions |
| /platform/v2/x402/verify | POST | Simulates payment on-chain, returns isValid |
| /platform/v2/x402/settle | POST | Executes payment on-chain, returns tx hash |
Base mainnet USDC EIP-712 domain: { name: "USD Coin", version: "2" }. These values are auto-populated by ExactEvmScheme's defaultMoneyConversion.
The facilitator supports:
eip155:8453(Base) — exact scheme, v2eip155:84532(Base Sepolia) — exact scheme, v2eip155:137(Polygon) — exact scheme, v2solana:*— exact scheme, v1base/base-sepolia— exact scheme, v1 (legacy network IDs)
Always use CAIP-2 format (eip155:8453) for v2.
Production hardening patterns
Rate limiting (in-memory)
const rateLimitMap = new Map<string, { count: number; resetAt: number }>();
function isRateLimited(ip: string): boolean {
const now = Date.now();
const entry = rateLimitMap.get(ip);
if (!entry || now >= entry.resetAt) {
rateLimitMap.set(ip, { count: 1, resetAt: now + 1000 });
if (rateLimitMap.size > 1000) { // Prevent memory leak
for (const [key, val] of rateLimitMap) {
if (now >= val.resetAt) rateLimitMap.delete(key);
}
}
return false;
}
entry.count++;
return entry.count > 10; // 10 req/sec
}
Place rate limiting AFTER the x402 middleware — agents paying shouldn't be rate-limited the same as free endpoints.
Redis cache (non-fatal)
const cached = await cache.get<ScanResult>(cacheKey);
if (cached) return c.json(cached);
// ... do work ...
await cache.set(cacheKey, result, 1800); // 30 min TTL
Critical pattern: Redis errors must be non-fatal. Wrap all cache operations in try/catch that silently fails. The API must work without cache — it just costs more RPC calls.
Graceful degradation (Promise.allSettled)
Run expensive checks in parallel and tolerate individual failures:
const [contractResult, explorerResult, marketResult] =
await Promise.allSettled([
analyzeContract(provider, token),
checkSourceVerified(chain, token, key),
getTokenPairs(chain, token),
]);
let checksCompleted = 0;
if (contractResult.status === 'fulfilled') { checksCompleted++; /* use data */ }
// Failed checks → use defaults, lower confidence
const confidence = checksCompleted / CHECKS_TOTAL;
This is the key to resilient APIs with multiple external dependencies. A failing DEXScreener shouldn't block the entire scan.
MCP bridge pattern
Expose x402 APIs as MCP tools for Claude Code / Cursor:
// mcp/server.ts — install: claude mcp add rug-scanner -- npx tsx mcp/server.ts
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const response = await fetch(`${API_URL}/scan`, { });
if (response.status === 402) {
return { content: [{ type: 'text', text: 'Payment required: $0.05 USDC' }], isError: true };
}
const result = await response.json();
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
});
The MCP server wraps the HTTP API. When x402 is active, it returns the 402 as a text error — the agent needs an x402-aware wallet to pay. Clean separation between tool discovery (MCP) and payment (x402).
Costs
- CDP facilitator: free (Coinbase subsidizes settlement gas as of April 2026).
- Your cost: only RPC calls (Alchemy free tier = 300M compute units / month).
- Revenue: $0.05 USDC per scan lands directly in your wallet.
Discovery & distribution checklist
After your first settled transaction, distribute across these channels:
| Channel | How | Priority |
|---------|-----|----------|
| x402 Bazaar | Automatic after first payment via CDP facilitator | Auto |
| /.well-known/x402.json | Serve from your API — agents use this for auto-discovery | High |
| /.well-known/agent-card.json | A2A (Google) agent discovery — declare skills + x402 extension | High |
| MCP directories (Glama, Smithery) | Publish as npm package, add glama.json to repo root | High |
| awesome-x402 lists | PR to xpaysh/awesome-x402 and Merit-Systems/awesome-x402 | Medium |
| x402.org ecosystem | PR to x402-foundation/x402 — add metadata.json in typescript/site/app/ecosystem/partners-data/<slug>/ | Medium |
| x402list.fun / x402scan.com | Auto-index from Bazaar, no manual action | Auto |
A2A agent card (minimal)
// Serve at GET /.well-known/agent-card.json
{
"name": "Your Service",
"description": "...",
"version": "1.0.0",
"capabilities": {
"extensions": [{
"uri": "https://github.com/google-a2a/a2a-x402/v0.1",
"description": "$X USDC on Base per call via x402.",
"required": true
}]
},
"skills": [{ "id": "...", "name": "...", "description": "...", "tags": [] }]
}
MCP npm package (minimal)
// package.json — key fields
{
"name": "@yourname/service-mcp",
"bin": { "service-mcp": "./dist/server.js" },
"files": ["dist"],
"keywords": ["mcp", "mcp-server", "x402"]
}
Add #!/usr/bin/env node shebang to the entry file. Users install with claude mcp add name -- npx @yourname/service-mcp.
Glama auto-indexing
Add glama.json to repo root:
{ "$schema": "https://glama.ai/mcp/schemas/server.json", "maintainers": ["YourGitHub"] }
Glama auto-discovers public GitHub repos with MCP servers. Claim ownership at glama.ai/mcp/servers.
Key learnings
- x402 "just works" once the plumbing is right. The SDK handles the complex EIP-3009 signing, base64 encoding, header negotiation. Most bugs are configuration, not protocol.
- Self-payment doesn't work. CDP facilitator rejects
from === to. Always test with a separate payer wallet. - The
ExactEvmSchemehandles USDC defaults automatically. You don't need to specify the USDC address, EIP-712 domain, or decimals —price: '$0.05'is enough. - v2 uses
deepEqualfor requirement matching. The client echoes back the exact requirements it received. If the server generates different requirements on the second request (e.g., due to middleware recreation), matching fails silently. - Passthrough when keys are missing is good DX. During local dev without CDP keys, the middleware should skip payment — not crash.
- Bazaar auto-indexing requires a real settled transaction. The
/.well-known/x402.jsonfile alone isn't enough — you need at least one paid transaction for the x402 Bazaar to discover your service.
Connection points
- This is the build journal for Rug Scanner — every error message and fix in the debugging playbook came from getting that API to its first settled transaction.
- Pairs with x402 Competitive Landscape — Live Services Analysis — the implementation answers "how do I ship this?", the landscape note answers "what should I ship?".