Skip to main content

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).
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.',
});
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

VarRequiredPurpose
EVM_PAYEE_ADDRESSyes0x… address that receives x402 and MPP payments. The zero address is rejected.
BASE_URLyes outside VercelOrigin 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.
VarRequiredPurpose
CDP_API_KEY_ID, CDP_API_KEY_SECRETwhen x402 is enabledCoinbase Developer Platform credentials for the default EVM facilitator. The free tier is enough.
X402_BUILDER_CODEnoBase Builder Code for ERC-8021 on-chain attribution. Credits each settled payment to your app on the Base dashboard.
SOLANA_PAYEE_ADDRESSnoAdds 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.
VarRequiredPurpose
MPP_SECRET_KEYwhen MPP is enabledServer-side MPP secret (openssl rand -hex 32).
MPP_CURRENCYwhen MPP is enabledTempo currency address. Use the exported TEMPO_USDC_ADDRESS constant for Tempo USDC.
MPP_OPERATOR_KEYfor .session()Signs server-side close/settle. Setting it auto-enables session mode. Must be the private key of EVM_PAYEE_ADDRESS.
MPP_FEE_PAYER_KEYnoSponsors client gas for channel open/topUp. Must resolve to a different address than the operator.
TEMPO_RPC_URLnoTempo 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

VarRequiredPurpose
KV_REST_API_URL, KV_REST_API_TOKENrecommended for productionUpstash / 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})`);
  },
};