Skip to main content
Every route picks exactly one auth mode. .paid(), .upTo(), and .session() are mutually exclusive pricing modes; .siwx() and .apiKey() compose with pricing.
MethodPurpose
.paid(price)Fixed, body-derived, or tiered payment up front (x402, MPP, or both).
.upTo(maxPrice)Handler-computed billing via charge(amount), settled once. x402 only.
.session({ unitCost, maxPrice })Per-unit billing over an MPP payment channel. Required for streaming. MPP only.
.siwx()Wallet identity, no payment.
.apiKey(resolver)X-API-Key or Authorization: Bearer <key>. Composes with pricing modes.
.unprotected()No auth.
Fixed price per request:
Fixed
router
  .route('search')
  .paid('0.01')
  .body(z.object({ q: z.string() }))
  .handler(async ({ body }) => search(body.q));
Compute the price from the parsed body: the challenge quotes the exact amount before payment. Requires .body(schema):
Body-derived
router
  .route('generate')
  .paid((body) => calculateCost(body), { maxPrice: '5.00' })
  .body(genSchema)
  .handler(async ({ body }) => generate(body));
maxPrice caps the computed amount. If the pricing function throws an HttpError, the request is rejected with that status before any payment; any other throw falls back to quoting maxPrice. Or pick a tier from a body field:
Tiered
router
  .route('upload')
  .paid({
    field: 'tier',
    tiers: {
      '10mb': { price: '0.02', label: '10 MB' },
      '100mb': { price: '0.20', label: '100 MB' },
    },
  })
  .body(uploadSchema)
  .handler(async ({ body }) => upload(body));

.upTo()

Handler-computed billing, x402 only. The handler calls charge(amount) as it works; the request settles once for the running total, capped at maxPrice:
router
  .route('research')
  .upTo('0.05')
  .body(schema)
  .handler(async ({ body, charge }) => {
    await charge('0.001');
    const extra = await deepDive(body);
    await charge('0.002');
    return extra;
  });
A charge() that would push the total past maxPrice throws a 400 CHARGE_OVER_CAP error and nothing settles: it is not silently clamped. Guard your loop if partial work should still be billed.

.session()

Per-unit billing over an MPP payment channel, MPP only. Requires MPP_OPERATOR_KEY (see Configuration). In request mode, .handler() bills exactly unitCost per request:
Request mode
router
  .route('llm')
  .session({ unitCost: '0.01', maxPrice: '0.05', unitType: 'request' })
  .body(schema)
  .handler(async ({ body }) => complete(body.prompt));
Streaming serves the session over SSE; each charge() bills one unit:
Streaming
router
  .route('stream')
  .session({ unitCost: '0.0001', maxPrice: '0.05', unitType: 'token' })
  .body(schema)
  .stream(async function* ({ body, charge }) {
    for await (const token of streamLLM(body.prompt)) {
      await charge();
      yield token;
    }
  });
Streaming is MPP-only: .stream() on any other pricing mode throws at registration.

.siwx()

Free routes gated by wallet identity. Unpaid requests get a 402 with a SIWX challenge; the verified wallet address is available to the handler:
router
  .route({ path: 'profile', method: 'GET' })
  .siwx()
  .handler(async ({ wallet }) => getProfile(wallet));

Pay once, replay free

.paid() and .upTo() compose with .siwx(): the first request pays normally, the wallet is recorded, and later requests with a valid SIWX signature skip payment:
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));
Serverless deployments need a real KV store for this: without one, entitlements live in a per-process Map and a wallet that paid on one instance gets charged again on another. See Configuration.

.apiKey()

The resolver receives the key from X-API-Key or Authorization: Bearer; return null to reject with 401:
router
  .route({ path: 'admin/users' })
  .apiKey(async (key) => db.admin.findByKey(key))
  .handler(async ({ account }) => db.user.findMany());

router
  .route({ path: 'gated' })
  .apiKey(resolver)
  .paid('0.01') // key AND payment
  .handler(fn);

Schemas and examples

Chain .body(), .query(), and .output() with Zod schemas. Types propagate through the builder, so body, query, and the expected return type are fully typed in the handler:
const SearchSchema = z.object({ q: z.string(), limit: z.number().default(10) });
const ResultSchema = z.object({ hits: z.array(z.string()) });

router
  .route('search')
  .paid('0.01')
  .body(SearchSchema)
  .output(ResultSchema)
  .handler(async ({ body }) => ({ hits: await db.search(body.q, body.limit) }));
  • .body(schema): parses and validates the JSON body, typed as ctx.body.
  • .query(schema): parses the query string and switches the route to GET, typed as ctx.query.
  • .output(schema): declares the response shape for OpenAPI generation. The runtime does not validate handler return values; call schema.parse() inside the handler if you want strict output validation.

Discovery examples

.inputExample() and .outputExample() attach examples to the OpenAPI document. Both are validated against the registered schemas at registration, so a drifted example fails at build time instead of misleading agents:
router
  .route('search')
  .paid('0.01')
  .body(SearchSchema)
  .inputExample({ q: 'coffee shops in soho', limit: 5 })
  .output(ResultSchema)
  .outputExample({ hits: ['Blue Bottle', 'La Colombe'] })
  .handler(async ({ body }) => search(body));

.description()

A one-line, human-readable summary of the route. It surfaces in the OpenAPI spec and llms.txt, so it’s often the first thing an agent reads when deciding whether to call you:
router
  .route('search')
  .paid('0.01')
  .description('Full-text search over indexed web pages.')
  .body(SearchSchema)
  .handler(async ({ body }) => search(body));

Path params

Paths declare {param} segments, extracted in every hosting mode:
router
  .route('drafts/{draftId}/commit')
  .unprotected()
  .handler(async ({ params }) => commit(params.draftId));

Pre-payment validation

.validate() runs before the 402 challenge, so callers are never charged for a request that was going to fail:
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: body parse → validate → 402 challenge → payment → handler. On paid routes, the paying retry always parses and validates the body before settlement, so a 400 never costs the caller money.

Throwing errors

Throw HttpError from handlers, pricing functions, or validators to reject with a specific status code. A plain Error with a numeric status property works the same way; anything else becomes a 500:
import { HttpError } from '@agentcash/router';

throw new HttpError('Fortune too long (max 100 chars)', 400);

// equivalent plain-error form
throw Object.assign(new Error('Domain taken'), { status: 409 });
In a body-derived pricing function, only HttpError instances are rethrown before the 402 challenge (so callers see the rejection without paying); any other throw (including a plain error with a status property) falls back to quoting maxPrice instead. Use HttpError when a pricing function needs to reject. Handlers can also return a Response directly for full control over status, headers, and body.