npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@agentcash/router

v1.20.0

Published

Unified route builder for Next.js App Router APIs with x402, MPP, SIWX, and API key auth

Readme


Install

pnpm add @agentcash/router
pnpm add zod  # peer dependency

The core is framework-agnostic (Web-standard Request/Response) — Next.js is one hosting option, not a dependency. See Hosting.

Environment

The recommended entry point reads its config from process.env. A copy-paste .env.example lives at the repo root.

x402 (auto-enabled when CDP_API_KEY_ID + CDP_API_KEY_SECRET are set)

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.

Solana

| 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. |

MPP (auto-enabled when MPP_SECRET_KEY is set)

| 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, so MPP_OPERATOR_KEY must be the private key of EVM_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);"

Other

| 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. |

Quick start

1. Create the router

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

network takes CAIP-2 identifiers only (eip155:8453, solana:…) — friendly names like base or base-sepolia fail the type check and throw unsupported_x402_network at construction. Import the exported constants (BASE_MAINNET_NETWORK, SOLANA_MAINNET_NETWORK) instead of hand-writing strings. The same applies to x402.accepts[i].network.

2. Register routes

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}/commitctx.params.draftId).

3. Serve it

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.

4. Discovery

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/x402 resource 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.json and /llms.txt.

Bazaar catalog metadata

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.

5. Call it

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"}'

Hosting

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:

Next.js catch-all (recommended)

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.

Next.js per-file

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() (or route({ method })) controls the verb advertised in discovery. The router defaults to POST (or GET when .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.ts must import '@/lib/routes-barrel' (a module importing every route file) or unloaded routes won't appear in the spec. (llms.txt renders 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.

Hono / Bun / Node / any fetch runtime

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.

Path params and basePath

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.

Auth modes

| 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);

.siwx() on paid routes. Pay once, identity-gated replays

.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-process Map, so a wallet that paid on instance A is treated as unpaid on instance B and the user gets charged again.

Reading a 402 challenge

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 the PAYMENT-REQUIRED response header (x402 v2), with MPP challenges in WWW-Authenticate. Don't res.json() these; decode the header.
  • SIWX routes (.siwx(), alone or composed with a pricing mode) return a 402 with a JSON body that mirrors the PAYMENT-REQUIRED header, including the sign-in-with-x extension. 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.

When the body is validated

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.

.paid(): fixed, args-derived, or tiered

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)

.upTo(): handler-computed, x402 only

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;
});

.session(): per-unit, MPP only

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.

Pre-payment validation

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.)

Plugin Hooks

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.

Debugging with onAlert

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