> ## Documentation Index
> Fetch the complete documentation index at: https://agentcash.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Routes and pricing

> Every auth mode the router supports, plus schemas, discovery examples, and error handling.

Every route picks exactly one auth mode. `.paid()`, `.upTo()`, and `.session()` are mutually exclusive pricing modes; `.siwx()` and `.apiKey()` compose with pricing.

<div className="auth-modes-table">
  | Method                             | Purpose                                                                             |
  | ---------------------------------- | ----------------------------------------------------------------------------------- |
  | `.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.                                                                            |
</div>

## `.paid()`

Fixed price per request:

```typescript Fixed theme={null}
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)`:

```typescript Body-derived theme={null}
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:

```typescript Tiered theme={null}
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`:

```typescript theme={null}
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;
  });
```

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

## `.session()`

Per-unit billing over an MPP payment channel, MPP only. Requires `MPP_OPERATOR_KEY` (see [Configuration](/router/configuration#mpp)). In request mode, `.handler()` bills exactly `unitCost` per request:

```typescript Request mode theme={null}
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:

```typescript Streaming theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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));
```

<Warning>
  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](/router/configuration#kv-store).
</Warning>

## `.apiKey()`

The resolver receives the key from `X-API-Key` or `Authorization: Bearer`; return `null` to reject with 401:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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.
