Two ways to initialize
createRouterFromEnv (recommended) reads process.env, validates every value up front, and throws a single RouterConfigError listing every problem at once. Protocols toggle on by presence: x402 when the CDP key pair is set, MPP when MPP_SECRET_KEY is set (at least one is required).
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.',
});
createRouter(config) takes an explicit RouterConfig: use it for custom networks, multiple payees, non-standard assets, or anything the env entry point doesn’t expose. It runs the same validation.
Core
| Var | Required | Purpose |
|---|
EVM_PAYEE_ADDRESS | yes | 0x… address that receives x402 and MPP payments. The zero address is rejected. |
BASE_URL | yes outside Vercel | Origin URL (https://api.example.com). Used as the 402 realm, OpenAPI server URL, and MPP memo prefix, so it must match your public domain. On Vercel it is auto-derived from VERCEL_PROJECT_PRODUCTION_URL, then VERCEL_URL. |
x402
Auto-enabled when both CDP vars are set.
| Var | Required | Purpose |
|---|
CDP_API_KEY_ID, CDP_API_KEY_SECRET | when x402 is enabled | Coinbase Developer Platform credentials for the default EVM facilitator. The free tier is enough. |
X402_BUILDER_CODE | no | Base Builder Code for ERC-8021 on-chain attribution. Credits each settled payment to your app on the Base dashboard. |
SOLANA_PAYEE_ADDRESS | no | Adds a Solana accept. Solana clients can only pay static-priced .paid() routes (.upTo() is Base-only and .session() is MPP-only). |
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 logs. That’s a graceful fallback, not a fatal error.
MPP
Auto-enabled when MPP_SECRET_KEY is set.
| Var | Required | Purpose |
|---|
MPP_SECRET_KEY | when MPP is enabled | Server-side MPP secret (openssl rand -hex 32). |
MPP_CURRENCY | when MPP is enabled | Tempo currency address. Use the exported TEMPO_USDC_ADDRESS constant for Tempo USDC. |
MPP_OPERATOR_KEY | for .session() | Signs server-side close/settle. Setting it auto-enables session mode. Must be the private key of EVM_PAYEE_ADDRESS. |
MPP_FEE_PAYER_KEY | no | Sponsors client gas for channel open/topUp. Must resolve to a different address than the operator. |
TEMPO_RPC_URL | no | Tempo JSON-RPC endpoint. Defaults to the public https://rpc.tempo.xyz. |
For local development, mint a throwaway keypair where payee and operator line up:
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);"
KV store
| Var | Required | Purpose |
|---|
KV_REST_API_URL, KV_REST_API_TOKEN | recommended for production | Upstash / Vercel KV. Backs SIWX nonces, SIWX entitlements, and MPP replay protection. |
Without a KV store, this state lives in per-process memory. On serverless or multi-instance deployments, a wallet that paid on instance A is treated as unpaid on instance B, so pay-once-then-replay routes charge users twice. Attach a KV store before production traffic.
Plugin hooks
Pass a RouterPlugin to observe the request lifecycle. All hooks are optional and fire-and-forget: they never delay the response.
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',
plugin: myPlugin,
});
Debugging with onAlert
The router reports internal warnings (failed payment verification, simulation failures, misconfiguration) through onAlert. With no plugin registered these are silently dropped, so a logging plugin is the fastest way to see why a request failed:
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})`);
},
};