The fastest way to ship an API on x402 and MPP.
x402 and MPP payments, compatible discovery, and minimal boilerplate. With @agentcash/router, agents on AgentCash and across the agentic commerce ecosystem can call your endpoints from day one.
pnpm add @agentcash/router
pnpm add zod # peer dependencyThe core is framework-agnostic (Web-standard Request/Response) — Next.js is one hosting option, not a dependency. See Hosting.
The recommended entry point reads its config from process.env. A copy-paste .env.example lives at the repo root.
At least one payment protocol must be configured: set the CDP key pair for x402, MPP_SECRET_KEY for MPP, or both.
| Var | Required | Purpose |
|---|---|---|
EVM_PAYEE_ADDRESS |
yes | EVM address that receives x402 and MPP payments (0x…, 20 bytes). Canonicalized to lowercase. The zero address is rejected. |
CDP_API_KEY_ID, CDP_API_KEY_SECRET |
when x402 is enabled | Coinbase Developer Platform credentials for the default EVM facilitator. Presence toggles x402 on — validity is never checked at boot, so placeholder values work for local dev (see callout below). Create API keys in the generous CDP free tier at https://portal.cdp.coinbase.com/projects/api-keys (signup requires phone verification, so provision keys ahead of time for CI/automated setups). T3 / @t3-oss/env-nextjs users must declare these in their env schema. |
Router construction phones the facilitator. Creating the router kicks off a background fetch of the facilitator's supported payment kinds — including during
next build. With missing or placeholder CDP keys you'll see[x402] facilitator /supported failed, using hardcoded baseline: …in build/dev logs. That's a graceful fallback, not a fatal error; paid routes still serve correct 402 challenges. Placeholder keys are a supported local-dev path — real keys are only needed for payment verification and settlement.
| Var | Required | Purpose |
|---|---|---|
X402_BUILDER_CODE |
no | Base Builder Code for ERC-8021 on-chain attribution (^[a-z0-9_]{1,32}$). Declared as the app code on every x402 challenge; the facilitator appends it to settlement calldata, crediting each settled payment to your app at https://dashboard.base.org. Register one under Settings → Builder Codes. |
SOLANA_PAYEE_ADDRESS |
no | When set, adds a Solana exact accept so the router takes Solana payments. .upTo() is Base-only and .session() is MPP-only. Solana clients can only pay static-priced .paid() routes. |
SOLANA_FACILITATOR_URL |
no | Override the Solana x402 facilitator. Defaults to DEFAULT_SOLANA_FACILITATOR_URL. |
| Var | Required | Purpose |
|---|---|---|
MPP_SECRET_KEY |
when MPP is enabled | Server-side MPP secret. Presence toggles MPP on. |
MPP_CURRENCY |
when MPP is enabled | Tempo currency address. Use TEMPO_USDC_ADDRESS for Tempo USDC. |
TEMPO_RPC_URL |
no | Tempo JSON-RPC endpoint for MPP on-chain verification. Defaults to the public DEFAULT_TEMPO_RPC_URL (https://rpc.tempo.xyz). Override only if you have a dedicated endpoint. |
MPP_OPERATOR_KEY |
no | Signs server-side close/settle. When set, MPP session mode is enabled automatically (required for .session(): both streaming and request-mode per-unit billing). Address must equal the payee. |
MPP_FEE_PAYER_KEY |
no | Sponsors client gas for channel open/topUp. Must resolve to a different address than MPP_OPERATOR_KEY (Tempo rejects fee-delegated txs where sender === feePayer). |
MPP session mode needs the payee's private key. Unlike x402 (address only),
.session()routes settle server-side, soMPP_OPERATOR_KEYmust be the private key ofEVM_PAYEE_ADDRESS. For local development, mint a throwaway keypair where the two line up:npm i -D viem # or pnpm add -D viem node -e "const {generatePrivateKey, privateKeyToAccount} = require('viem/accounts'); const pk = generatePrivateKey(); console.log('EVM_PAYEE_ADDRESS=' + privateKeyToAccount(pk).address); console.log('MPP_OPERATOR_KEY=' + pk);"
| Var | Required | Purpose |
|---|---|---|
BASE_URL |
yes outside Vercel | Origin URL (https://api.example.com). Load-bearing: used as the 402 realm, OpenAPI server URL, and MPP memo prefix. Must match the public domain. On Vercel, createRouterFromEnv auto-derives this from VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL. |
VERCEL_PROJECT_PRODUCTION_URL, VERCEL_URL |
no | Vercel system env vars used as fallbacks when BASE_URL is unset. Do not set these manually; Vercel provides them during builds and runtime. |
KV_REST_API_URL, KV_REST_API_TOKEN |
no | Upstash / Vercel KV. Backs SIWX nonce, SIWX entitlement, and MPP replay. In-memory fallback is unsafe in serverless production. Providing a Kv Store is highly recommended. |
There are two ways to initialize. Pick one.
Option A: createRouterFromEnv (recommended). Reads process.env, validates every value up front, and throws a single RouterConfigError with every problem at once. Auto-enables x402 when CDP_API_KEY_ID/CDP_API_KEY_SECRET are set, auto-enables MPP when MPP_SECRET_KEY is set (at least one of the two is required), auto-adds a Solana accept when SOLANA_PAYEE_ADDRESS is set, auto-enables MPP session mode when MPP_OPERATOR_KEY is set.
// lib/router.ts
import { createRouterFromEnv } from '@agentcash/router';
export const router = createRouterFromEnv({
title: 'My API',
description: 'Pay-per-call search.',
guidance: 'POST /search with { q: string }. Returns top 10 results.',
});No CDP keys yet? Set placeholder values for
CDP_API_KEY_ID/CDP_API_KEY_SECRET— the router boots, logs a fallback warning, and paid routes serve correct 402 challenges. Real keys are only needed to verify and settle actual payments (and Coinbase signup requires phone verification, so it can't be fully automated).
Option B: build a RouterConfig and pass it to createRouter. Use this when you need custom networks, multiple payees, non-standard assets, or any setting createRouterFromEnv doesn't expose. createRouter runs the same validation against the RouterConfig shape.
// lib/router.ts
import { createRouter, BASE_MAINNET_NETWORK } from '@agentcash/router';
export const router = createRouter({
baseUrl: 'https://api.example.com',
payeeAddress: '0x…',
network: BASE_MAINNET_NETWORK,
protocols: ['x402'],
x402: {
accepts: [
/* … */
],
},
discovery: {
title: 'My API',
version: '1.0.0',
description: 'Pay-per-call search.',
guidance: 'POST /search with { q: string }. Returns top 10 results.',
},
});
networktakes CAIP-2 identifiers only (eip155:8453,solana:…) — friendly names likebaseorbase-sepoliafail the type check and throwunsupported_x402_networkat construction. Import the exported constants (BASE_MAINNET_NETWORK,SOLANA_MAINNET_NETWORK) instead of hand-writing strings. The same applies tox402.accepts[i].network.
One side-effect module registers every route. Importing it populates the registry; the host (next step) serves whatever is registered.
// lib/routes.ts
import { z } from 'zod';
import { router } from './router';
router
.route('search')
.paid('0.01')
.body(z.object({ q: z.string() }))
.handler(async ({ body }) => search(body.q));
router
.route({ path: 'inbox/status', method: 'GET' })
.siwx()
.handler(async ({ wallet }) => getStatus(wallet));
router
.route({ path: 'health', method: 'GET' })
.unprotected()
.handler(async () => ({ status: 'ok' }));Every route picks exactly one auth mode — .paid(), .upTo(), .session(), .siwx(), .apiKey(), or .unprotected() — see Auth modes. Paths may declare {param} segments (drafts/{draftId}/commit → ctx.params.draftId).
Next.js — one catch-all file (recommended):
// app/api/[[...route]]/route.ts
import '@/lib/routes'; // side-effect import: registers all routes
import { router } from '@/lib/router';
import { nextHandlers } from '@agentcash/router/next';
export const { GET, POST, PUT, PATCH, DELETE } = nextHandlers(router);Hono / Bun / Node / any fetch runtime:
import { serve } from '@hono/node-server';
import './routes';
import { router } from './router';
serve({ fetch: router.fetch, port: 3000 });Per-file Next.js routes (export const POST = router.route(…)…handler(…)) also work — see Hosting for all three modes, path params, and basePath.
router.fetch (and therefore the catch-all) serves /{basePath}/openapi.json and /{basePath}/llms.txt automatically, plus the root aliases on fetch runtimes. On Next.js, add the conventional root paths with two one-line route files:
// app/openapi.json/route.ts — OpenAPI 3.1 at GET <origin>/openapi.json
import '@/lib/routes';
import { router } from '@/lib/router';
export const GET = router.openapi();// app/llms.txt/route.ts — plain-text agent guidance at GET <origin>/llms.txt
import { router } from '@/lib/router';
export const GET = router.llmsTxt();Deprecated:
router.wellKnown()(the/.well-known/x402resource listing) is no longer a recommended discovery surface. The handler still works if you already serve it — existing x402-native clients won't break — but new integrations should rely on/openapi.jsonand/llms.txt.
x402 facilitators persist service metadata from every settled payment into the Bazaar discovery catalog. The router forwards it from discovery config onto each 402 challenge's resource block:
createRouterFromEnv({
title: 'Quote API', // serviceName defaults to title when it fits (≤32 ASCII chars)
serviceName: 'Quote API', // explicit override
tags: ['quotes', 'wisdom'], // ≤5 tags, each ≤32 chars; defaults to the route's OpenAPI tag
iconUrl: 'https://example.com/icon.png', // HTTPS, ≤2048 chars
...
});Invalid values fail at startup with structured config issues rather than being silently dropped by the facilitator.
Unpaid requests get a 402 whose challenge lives in headers (see Reading a 402 challenge):
curl -i -X POST https://api.example.com/api/search \
-H 'content-type: application/json' -d '{"q":"hello"}'Pay it with any x402/MPP client:
# AgentCash CLI — auto-negotiates x402 or MPP
npx agentcash fetch https://api.example.com/api/search -m POST -b '{"q":"hello"}'
# mppx CLI — MPP reference client
npx mppx https://api.example.com/api/search -J '{"q":"hello"}'The router speaks Web-standard Request/Response and dispatches through an embedded Hono app, so the same route definitions run on any fetch runtime. Three hosting modes:
One route file serves every registered route — no per-route files, no discovery barrel:
// app/api/[[...route]]/route.ts
import '@/lib/routes'; // side-effect import: registers all routes
import { router } from '@/lib/router';
import { nextHandlers } from '@agentcash/router/next';
export const { GET, POST, PUT, PATCH, DELETE } = nextHandlers(router);The catch-all serves /{basePath}/* (default /api/*), including /api/openapi.json and /api/llms.txt. Unmatched paths get the notFound() rediscovery envelope automatically. The root discovery aliases (/openapi.json, /llms.txt) still need their own route files (export const GET = router.openapi()) or a middleware rewrite into the catch-all.
One route file per endpoint — still the right fit when you want per-route files:
// app/api/search/route.ts
export const POST = router.route('search').paid('0.01').body(schema).handler(handler);Three per-file specifics the catch-all mode makes obsolete:
- Exported const name vs
.method(). The name you export (GET/POST) controls which verb Next.js serves;.method()(orroute({ method })) controls the verb advertised in discovery. The router defaults toPOST(orGETwhen.query()is used) — any other GET route must declare it explicitly or discovery advertises the wrong verb. - Discovery needs a barrel. Next.js lazy-loads route files on first hit, so
app/openapi.json/route.tsmustimport '@/lib/routes-barrel'(a module importing every route file) or unloaded routes won't appear in the spec. (llms.txtrenders from static config only and doesn't need it.) - Unmatched route fallback. Mount
router.notFound()in a catch-all (app/api/[[...path]]/route.ts, one export per verb) so stale agent calls get a JSON 404 with rediscovery links instead of an HTML error page.
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
const app = new Hono();
app.route('/', router.hono()); // or use router.fetch directly
serve({ fetch: app.fetch, port: 3000 });router.fetch(request) is a standard fetch handler; router.hono() exposes the internal app for mounting into a larger one. See examples/hono for a runnable server.
Route paths may declare {param} segments, extracted identically in every hosting mode:
router
.route('drafts/{draftId}/commit')
.unprotected()
.handler(async ({ params }) => commit(params.draftId));RouterConfig.basePath (default 'api') controls the mount/advertised prefix: routes serve at {baseUrl}/{basePath}/{path}. Pass an empty string to mount at the origin root.
| Method | Purpose |
|---|---|
.paid(price) |
Fixed, args-derived, or tiered payment up front (x402, MPP, or both). |
.upTo(maxPrice) |
Handler-computed billing; handler calls charge(amount) and the request settles once for the running total. x402 only. |
.session({ unitCost, maxPrice }) |
Per-unit billing over an MPP payment channel (the MPP session intent). .handler() bills exactly unitCost; .stream() calls charge() per yield. MPP only. Streaming requires this. (.metered({ tickCost, ... }) remains as a deprecated alias.) |
.siwx() |
Wallet identity, no payment. Returns 402 with a SIWX challenge. |
.apiKey(resolver) |
X-API-Key or Authorization: Bearer <key>. Composes with .paid() / .upTo() / .session(). |
.unprotected() |
No auth. |
router
.route({ path: 'admin/users' })
.apiKey(async (key) => db.admin.findByKey(key)) // null => 401
.handler(async ({ account }) => db.user.findMany());
router
.route({ path: 'gated' })
.apiKey(resolver)
.paid('0.01') // key AND payment
.handler(fn);.paid() and .upTo() compose with .siwx() for a pay-once-then-replay-for-free model. The first request settles normally (x402 payment); on success the wallet is recorded in the entitlement KV. Subsequent requests that present a valid SIWX signature for that wallet skip payment and run the handler directly. On .upTo() routes, charge(amount) becomes a no-op on the SIWX replay path. The handler can continue to call the route unconditionally.
router
.route({ path: 'inbox' })
.paid('0.01')
.siwx() // first call pays $0.01, later calls present a SIWX sig instead
.handler(async ({ wallet }) => getInbox(wallet));.session() is mutually exclusive with .siwx() — per-unit MPP billing has no entitlement model — and the builder throws at registration if you combine them.
Gotcha: serverless / multi-instance deployments must provide a real
kvStore(Upstash / Vercel KV). Without one the entitlement is kept in a per-processMap, so a wallet that paid on instance A is treated as unpaid on instance B and the user gets charged again.
What an unpaid request gets back depends on the route's auth mode:
- Payment-only routes (
.paid(),.upTo(),.session()without.siwx()) return a 402 with an empty body. The entire challenge — accepts, price, schema — is base64-encoded in thePAYMENT-REQUIREDresponse header (x402 v2), with MPP challenges inWWW-Authenticate. Don'tres.json()these; decode the header. - SIWX routes (
.siwx(), alone or composed with a pricing mode) return a 402 with a JSON body that mirrors thePAYMENT-REQUIREDheader, including thesign-in-with-xextension. Header and body are always identical.
Every 402 also carries an X-Agent-Identity response header: an optional DID-auth challenge (nonce, domain/route binding, expiry). Clients that hold a DID may sign it and send the proof back in X-Agent-Identity on the retry; the verified DID is then available to handlers as ctx.actor. It is fully optional — it never gates the request and is unrelated to SIWX or payment. Clients without a DID can ignore it.
For args-derived (.paid(fn)) and tiered pricing, and for routes with .validate() or a checkout session, the body is parsed before the 402 challenge so the challenge can quote an accurate price (and .validate() can reject with its own status). On every other paid route, a bare unpaid probe gets its 402 without the body being inspected — a malformed body still yields 402, not 400. The paying retry then parses and validates the body before payment verification and settlement, so a 400 never costs the caller money.
.paid(), .upTo(), and .session() are mutually exclusive pricing modes: pick one per route.
Static.
.paid('0.02')Args-derived. Compute the price from the parsed body. Throw HttpError to reject before the 402 challenge.
.paid((body) => calculateCost(body), { maxPrice: '5.00' })
.body(genSchema)maxPrice caps the computed amount and acts as a fallback on non-HttpError exceptions thrown by the pricing function (HttpError is always rethrown). Without maxPrice, the route trusts your function fully (no cap, no fallback) and returns 500 on errors.
Tiered.
.paid({
field: 'tier',
tiers: {
'10mb': { price: '0.02', label: '10 MB' },
'100mb': { price: '0.20', label: '100 MB' },
},
})
.body(uploadSchema)Handler calls charge(amount) one or more times; the request settles once for the accumulated total, capped at maxPrice. A charge() that would push the running total past maxPrice throws a 400 CHARGE_OVER_CAP error (the request fails and nothing settles) — it is not silently clamped, so guard your loop if partial work should still be billed. Requires an 'upto' accept on at least one configured x402 network (createRouterFromEnv auto-adds one on Base).
.upTo('0.05')
.body(schema)
.handler(async ({ body, charge }) => {
await charge('0.001');
// ... more work ...
await charge('0.002');
return result;
});Per-unit billing over an MPP payment channel — the MPP session intent. Requires MPP_OPERATOR_KEY (createRouterFromEnv auto-enables session mode when it's set). unitCost maps to the MPP session challenge's per-unit amount; unitType names the unit; maxPrice is a router-enforced total ceiling (MPP itself only bounds spend by voucher headroom and deposit).
Request-mode. .handler() bills exactly unitCost on each request:
.session({ unitCost: '0.01', maxPrice: '0.05', unitType: 'request' })
.handler(async ({ body }) => { ... });Streaming. .stream() serves the session over SSE; each charge() call bills one unit, up to maxPrice:
.session({ unitCost: '0.0001', maxPrice: '0.05', unitType: 'token' })
.stream(async function* ({ body, charge }) {
for await (const token of streamLLM(body.prompt)) {
await charge();
yield token;
}
});
.metered({ tickCost, ... })is a deprecated alias for.session({ unitCost, ... })and behaves identically.
Streaming is MPP-only. .stream() on a .paid() / .upTo() / .unprotected() route throws at registration.
For checks that need a DB lookup before quoting a price:
router
.route({ path: 'domain/register' })
.paid(calculatePrice, { maxPrice: '10.00' })
.body(RegisterSchema)
.validate(async (body) => {
if (await isDomainTaken(body.domain)) {
throw Object.assign(new Error('Domain taken'), { status: 409 });
}
})
.handler(async ({ body, wallet }) => registerDomain(body.domain, wallet));Pipeline order on these routes: body parse -> validate -> 402 challenge -> payment -> handler. (.validate() is one of the triggers for pre-challenge body parsing — see When the body is validated.)
import { createRouterFromEnv, type RouterPlugin } from '@agentcash/router';
const myPlugin: RouterPlugin = {
onRequest(meta) {},
onPaymentVerified(ctx, payment) {},
onPaymentSettled(ctx, settlement) {},
onResponse(ctx, response) {},
onError(ctx, error) {},
onAlert(ctx, alert) {},
};
export const router = createRouterFromEnv({
title: 'My API',
description: '…',
guidance: '…',
plugin: myPlugin,
});All hooks are optional and fire-and-forget; they never delay the response. Use hooks to add additional telemetry or flexibility to your resource's lifecycle.
The router reports internal warnings and errors (failed payment verification, simulation failures, misconfiguration) through onAlert: with no plugin registered these messages are silently dropped, so wiring up a logging plugin is the fastest way to see why a request failed. Forward onAlert (and onError) to your logs:
const loggingPlugin: RouterPlugin = {
onAlert(_ctx, alert) {
(alert.level === 'error' ? console.error : console.warn)(
`[router:${alert.route}] ${alert.message}`,
alert.meta ?? '',
);
},
onError(_ctx, error) {
console.error(`[router] ${error.status} ${error.message} (settled=${error.settled})`);
},
};