@agentcash/router
v0.6.8
Published
Unified route builder for Next.js App Router APIs with x402, MPP, SIWX, and API key auth
Readme
@agentcash/router
Unified route builder for Next.js App Router APIs with x402 payments, MPP payments, SIWX authentication, and API key auth.
Eliminates ~80-150 lines of boilerplate per route. Routes become 3-6 lines.
Install
pnpm add @agentcash/routerPeer dependencies:
pnpm add next zod @x402/core @x402/evm @x402/extensions @coinbase/x402 zod-openapi
# Optional: for MPP support
pnpm add mppxEnvironment Setup
The router uses the default facilitator from @coinbase/x402 for x402 payments, which requires CDP API keys:
CDP_API_KEY_ID=your-key-id
CDP_API_KEY_SECRET=your-key-secretFor Next.js apps with env validation (T3 stack, @t3-oss/env-nextjs): Add these to your env schema — Next.js doesn't expose undeclared env vars to process.env.
// src/env.js
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
CDP_API_KEY_ID: z.string(),
CDP_API_KEY_SECRET: z.string(),
},
runtimeEnv: {
CDP_API_KEY_ID: process.env.CDP_API_KEY_ID,
CDP_API_KEY_SECRET: process.env.CDP_API_KEY_SECRET,
},
});Without these keys, x402 routes will fail to initialize (empty 402 responses, no payment header).
Quick Start
1. Create the router (once per service)
// lib/routes.ts
import { createRouter } from '@agentcash/router';
export const router = createRouter({
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
});2. Define routes
Paid route (x402)
// app/api/search/route.ts
import { router } from '@/lib/routes';
import { searchSchema, searchResponseSchema } from '@/lib/schemas';
export const POST = router.route('search')
.paid('0.01')
.body(searchSchema)
.output(searchResponseSchema)
.description('Search the web')
.handler(async ({ body }) => search(body));SIWX-authenticated route
export const GET = router.route('inbox/status')
.siwx()
.query(statusQuerySchema)
.handler(async ({ query, wallet }) => getStatus(query, wallet));Unprotected route
export const GET = router.route('health')
.unprotected()
.handler(async () => ({ status: 'ok' }));3. Auto-discovery
// app/.well-known/x402/route.ts
import { router } from '@/lib/routes';
import '@/lib/routes/barrel'; // ensures all routes are imported
export const GET = router.wellKnown();
// app/openapi.json/route.ts
import { router } from '@/lib/routes';
import '@/lib/routes/barrel';
export const GET = router.openapi({ title: 'My API', version: '1.0.0' });API
createRouter(config)
Creates a ServiceRouter instance.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| payeeAddress | string | required | Wallet address to receive payments |
| network | string | 'eip155:8453' | Blockchain network |
| plugin | RouterPlugin | undefined | Observability plugin |
| prices | Record<string, string> | undefined | Central pricing map (auto-applied) |
| siwx.nonceStore | NonceStore | MemoryNonceStore | Custom nonce store |
| mpp | { secretKey, currency, recipient? } | undefined | MPP config |
Route Builder
The fluent builder ensures compile-time safety:
.paid(price)/.paid(fn, { maxPrice })/.paid({ field, tiers })- Payment auth.siwx()- SIWX wallet auth.apiKey(resolver)- API key auth (composable with.paid()).unprotected()- No auth.body(zodSchema)- Request body validation.query(zodSchema)- Query parameter validation.output(zodSchema)- Response schema (for OpenAPI).description(text)- Route description (for OpenAPI).provider(name, config?)- Provider monitoring (see Provider Monitoring).handler(fn)- Terminal method, returns Next.js handler
Pricing Modes
Static - Fixed price for all requests:
router.route('search').paid('0.02')Dynamic - Calculate price based on request body:
router.route('gen')
.paid((body) => calculateCost(body.imageSize, body.quality))
.body(imageGenSchema)
.handler(async ({ body }) => generate(body));Dynamic with safety net - Cap at maxPrice if calculation exceeds, fallback to maxPrice on errors:
router.route('compute')
.paid((body) => calculateExpensiveOperation(body), { maxPrice: '10.00' })
.body(computeSchema)
.handler(async ({ body }) => compute(body));Tiered - Price based on a specific field value:
router.route('upload').paid({
field: 'tier',
tiers: {
'10mb': { price: '0.02', label: '10 MB' },
'100mb': { price: '0.20', label: '100 MB' },
},
}).body(uploadSchema)maxPrice Semantics (v0.3.1+)
maxPrice is optional for dynamic pricing and acts as a safety net:
Capping: If
calculateCost(body)returns"15.00"butmaxPrice: "10.00", the client is charged$10.00(capped) and a warning alert fires.Fallback: If
calculateCost(body)throws an error andmaxPriceis set, the route falls back tomaxPrice(degraded mode) and an alert fires. WithoutmaxPrice, the route returns 500.Trust mode: No
maxPricemeans full trust in your pricing function (no cap, no fallback).
Best practices:
- ✅ Always set
maxPricefor production routes (safety net) - ✅ Use
maxPricefor routes with external dependencies (pricing APIs) - ✅ Monitor alerts for capping events (indicates pricing bug)
- ⚠️ Skip
maxPriceonly for well-tested, unbounded pricing (e.g., per-GB storage)
Example with safety net:
router.route('ai-gen')
.paid(async (body) => {
// External pricing API (can fail)
const res = await fetch('https://pricing.example.com/calculate', {
method: 'POST',
body: JSON.stringify(body),
});
return res.json().price;
}, { maxPrice: '5.00' }) // Fallback if API is down
.body(genSchema)
.handler(async ({ body }) => generate(body));Dual Protocol (x402 + MPP)
router.route('search')
.paid('0.01', { protocols: ['x402', 'mpp'] })
.body(schema)
.handler(fn);Handler Context
interface HandlerContext<TBody, TQuery> {
body: TBody; // Parsed + validated
query: TQuery; // Parsed + validated
request: NextRequest; // Raw request
wallet: string | null; // Verified wallet address
account: unknown; // From .apiKey() resolver
alert: AlertFn; // Fire observability alerts
setVerifiedWallet: (addr: string) => void;
}RouterPlugin
Pluggable observability. All hooks are optional and fire-and-forget.
import { createRouter, 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) { /* ... */ },
onProviderQuota(ctx, event) { /* ... */ },
};
export const router = createRouter({
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
plugin: myPlugin,
});Built-in consolePlugin() logs lifecycle events:
import { createRouter, consolePlugin } from '@agentcash/router';
export const router = createRouter({
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
plugin: consolePlugin(),
});Central Pricing Map
For services with many static-priced routes:
const router = createRouter({
payeeAddress: process.env.X402_PAYEE_ADDRESS!,
prices: {
'search': '0.02',
'lookup': '0.05',
},
});
// Price auto-applied, no .paid() needed
export const POST = router.route('search')
.body(schema)
.handler(fn);Provider Monitoring
Routes that wrap third-party APIs can declare monitoring behavior per-provider. This surfaces quota/balance information through the plugin system and registers cron-checkable monitors.
Why
Upstream providers report remaining quota in different ways:
| Pattern | Example | How detected |
|---------|---------|-------------|
| Balance in response headers | X-RateLimit-Remaining: 482 | extractQuota reads headers |
| Balance in response body | { rateLimit: { remaining: 50 } } | extractQuota reads result |
| Separate health-check endpoint | Apollo /credits endpoint | monitor function (cron) |
| Overages auto-charged at same rate | Exa, Firecrawl | overage: 'same-rate' |
| Overages at increased rate | Some SaaS APIs | overage: 'increased-rate' |
| No overages, immediate stoppage | Whitepages | overage: 'hard-stop' |
The .provider() method handles all six patterns through a single interface.
Basic usage
export const POST = router.route('search')
.paid('0.01')
.provider('exa', {
extractQuota: (result, headers) => ({
remaining: (result as any).rateLimit?.remaining ?? null,
limit: (result as any).rateLimit?.limit ?? null,
}),
warn: 100,
critical: 10,
})
.body(searchSchema)
.handler(async ({ body }) => exaClient.search(body));After every successful handler response, extractQuota runs with the raw handler result and the response headers. The router computes a level (healthy, warn, critical) based on thresholds and fires onProviderQuota on the plugin.
ProviderConfig
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| extractQuota | (result, headers) => QuotaInfo \| null | — | Inline quota extraction after each request |
| monitor | () => Promise<QuotaInfo \| null> | — | Standalone health check (for cron) |
| overage | 'same-rate' \| 'increased-rate' \| 'hard-stop' | 'same-rate' | What happens when quota hits zero |
| warn | number | — | Fire warn level when remaining <= this |
| critical | number | — | Fire critical level when remaining <= this |
QuotaInfo
interface QuotaInfo {
remaining: number | null; // Credits/calls remaining
limit: number | null; // Total quota (null if unknown)
spend?: number; // Credits consumed this request
}Threshold logic
| Condition | Level |
|-----------|-------|
| remaining === null | healthy (no data to compare) |
| remaining <= critical | critical |
| remaining <= warn | warn |
| Otherwise | healthy |
Plugin hook
interface ProviderQuotaEvent {
provider: string; // Provider name from .provider()
route: string; // Route key
remaining: number | null;
limit: number | null;
spend?: number;
level: 'healthy' | 'warn' | 'critical';
overage: 'same-rate' | 'increased-rate' | 'hard-stop';
message: string; // Human-readable summary
}Handle in your plugin:
const myPlugin: RouterPlugin = {
onProviderQuota(ctx, event) {
if (event.level === 'critical') {
discord.alert(`${event.provider}: ${event.remaining} remaining`);
}
clickhouse.insert('provider_quota', event);
},
};Cron monitors
For providers that require a separate API call to check balance (not available inline in response), register a monitor function:
export const POST = router.route('people/search')
.paid('0.05')
.provider('apollo', {
monitor: async () => {
const res = await fetch('https://api.apollo.io/v1/credits', {
headers: { 'X-Api-Key': process.env.APOLLO_KEY! },
});
const data = await res.json();
return { remaining: data.credits, limit: null };
},
overage: 'hard-stop',
warn: 500,
critical: 50,
})
.body(searchSchema)
.handler(fn);Retrieve all registered monitors via router.monitors():
// cron.ts — run every 5 minutes
import { router } from '@/lib/routes';
import '@/lib/routes/barrel';
for (const entry of router.monitors()) {
const quota = await entry.monitor();
if (!quota) continue;
const level = quota.remaining !== null && quota.remaining <= (entry.critical ?? 0)
? 'critical'
: quota.remaining !== null && quota.remaining <= (entry.warn ?? 0)
? 'warn'
: 'healthy';
if (level !== 'healthy') {
alert(`${entry.provider} (${entry.route}): ${quota.remaining} remaining [${level}]`);
}
}monitors() returns:
interface MonitorEntry {
provider: string;
route: string;
monitor: () => Promise<QuotaInfo | null>;
overage: OveragePolicy;
warn?: number;
critical?: number;
}Provider name only
If you just want to tag a route with its provider for logging/tracing, pass only the name:
router.route('health')
.unprotected()
.provider('internal')
.handler(async () => ({ status: 'ok' }));Safety guarantees
extractQuotaruns fire-and-forget — exceptions are caught and swallowedextractQuotaonly runs whenresponse.status < 400(no quota extraction on errors)- The plugin hook is non-blocking — it never delays the response to the caller
- Missing thresholds are fine — without
warn/critical, level is alwayshealthy
License
MIT
