---
title: API pipeline
description: The oRPC handler, the middleware chain, one problem+json encoding, and authority before validation.
sidebar:
  order: 4
---

Every endpoint of `apps/api` is an oRPC procedure behind one `OpenAPIHandler`, mounted from a plain Worker `fetch`.
The API does not use Hono; the MCP server does. See [How the API is built on oRPC](/architecture/orpc) for adding a
procedure.

The Worker `fetch` (`apps/api/src/index.ts`, `handleRequest`) does four things around the handler: it builds the initial
context, answers an unmatched route with the same problem as every other failure, finishes error responses, and logs the
completion once.

```mermaid
flowchart LR
  F[Worker fetch<br/>initial context] --> H[OpenAPIHandler<br/>plugins, decode input]
  H --> CI[client interceptor<br/>toORPCError]
  CI --> RQ[withRequest] --> DO[withDomain] --> AU[authenticate] --> AD[admitCaller] --> AC[withActor] --> GU[guard] --> RL[rateLimit] --> V[input validation] --> HND[handler] --> OV[output validation]
  OV --> B[boundary<br/>problem media type,<br/>304, completion log]
  CI -. thrown errors .-> B
```

## The handler

`createHandler` (`apps/api/src/handler.ts`) builds the `OpenAPIHandler` with:

- `ResponseHeadersHandlerPlugin`, which merges `context.resHeaders` into every response, errors included. This is how
  `WWW-Authenticate`, `DPoP-Nonce`, `Retry-After`, `Location` and `Cache-Control` leave a procedure or middleware.
- `RequestLimitHandlerPlugin` with a 256 KiB cap on every body. Conversation writes are also refused above 64 KiB by a
  `Content-Length` check, and security intents above 8 KiB by a bounded reader.
- a client interceptor that turns anything middleware or a handler throws into the single error encoding (below).
- `customErrorResponseBodyEncoder: encodeProblem`, and an event-stream keep-alive comment every 15 seconds.

There is no RPC protocol, no batching and no CORS: no browser calls the API cross-origin.

A request body needs `Content-Type: application/json` to be read as JSON; oRPC treats a body without a content type as
text. Malformed JSON fails during decoding, before authentication, so a bad body without a token is a 400, not a 401.

## Middleware chain

The chain is the router-level builder `v1` in `apps/api/src/context/builders.ts`. Router-level middleware wraps input
validation, so a request with a bad token never reaches validation. oRPC v2 does not
dedupe middleware, and a test pins that each stage runs once per procedure.

| Stage          | Adds to the context                               | What it does                                                                                                                                                                      |
| -------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `withRequest`  | `config`, `requestId`, `log`, `now`, `challenge`  | Parses configuration once per isolate, writes `X-Request-Id`, echoes a valid `traceparent`, defaults `Cache-Control: no-store`, picks the challenge scheme from the request alone |
| `withDomain`   | `ctx`                                             | The domain context: database, clock and lazy providers                                                                                                                            |
| `authenticate` | `verified`                                        | Reads the token from `Authorization` only, verifies it, classifies the caller. A DPoP request gets a fresh `DPoP-Nonce` on every response                                         |
| `admitCaller`  | `caller`, `callerContext`, guest and shopper data | Resolves a forwarded agent to the caller it speaks for and admits only the headers that belong to the caller kind                                                                 |
| `withActor`    | `actor`, `authority`                              | Live domain authority: lifecycle, delegation, and for Poppy the token scopes intersected with the live Session scopes. The conversation variant also forbids browser sessions     |
| `guard`        | `actor` (narrowed)                                | Reads the procedure's `api` metadata: callers, Poppy `agentScopes`, API scopes, and delegated scope translation. Names the operation for the log                                  |
| `rateLimit`    | none                                              | Exact D1 counters keyed by `operationId` for guests, and the Poppy Session and client budgets                                                                                     |

There is no request idempotency. A request that sends `Idempotency-Key` is accepted and the header is ignored, so a
retried order-placing call places another order; callers check the orders before retrying. Double orders from the
storefront are prevented the ordinary way: the form disables while it submits, and a submission after a paid order finds
the cart empty and is refused.

## One error encoding

Everything thrown becomes an `ORPCError` whose code is the HTTP class and whose `data` holds the problem members
(`toORPCError`, `apps/api/src/errors`). `encodeProblem` writes the RFC 9457 body with a required `error` member, the stable
code Poppy needs (spec 7.13):

```json
{
  "type": "https://docs.tarnandtimber.com/api/problems/cart_empty",
  "title": "Unprocessable Content",
  "status": 422,
  "error": "cart_empty",
  "detail": "The cart has no lines.",
  "instance": "<X-Request-Id>"
}
```

Optional members are `issues` (validation, at most 20, each with `path`, `code`, `message`), `scope` (insufficient scope)
and `conversation_id` (`direct_conversation_open`). Domain errors may carry only `Retry-After`, `RateLimit` and
`RateLimit-Policy`; challenges and nonces are written by `toORPCError` alone. Unexpected failures are 500
`internal_error` with no `detail`, and are logged as `api_error`.

| Failure                              | Status | `error`                                | Challenge                                                                |
| ------------------------------------ | ------ | -------------------------------------- | ------------------------------------------------------------------------ |
| No token on `/v1`                    | 401    | `invalid_token`                        | `Bearer error="invalid_token", DPoP error="invalid_token", algs="ES256"` |
| Malformed, expired or rejected token | 401    | `invalid_token`                        | In the scheme chosen from the request, before verification               |
| Poppy token sent as `Bearer`         | 401    | `invalid_token`                        | `DPoP error="invalid_token", algs="ES256"`                               |
| DPoP proof or nonce failure          | 401    | `invalid_dpop_proof`, `use_dpop_nonce` | `DPoP error="..."` and `DPoP-Nonce`                                      |
| Ended account access                 | 401    | `account_access_ended`                 | `invalid_token`                                                          |
| Wrong caller kind                    | 403    | `caller_not_allowed`                   | none                                                                     |
| Missing API or account scope         | 403    | `insufficient_scope` with `scope`      | Poppy callers are told account scopes, never API scopes                  |
| Not signed in to the account         | 403    | `sign_in_required`                     | `<scheme> error="sign_in_required"`                                      |
| Validation                           | 400    | `invalid_request` with `issues`        | none                                                                     |
| Rate limit                           | 429    | `rate_limited`                         | `Retry-After`                                                            |
| Verifier or Auth0 proxy outage       | 503    | `unavailable`                          | `Retry-After: 5`                                                         |

The challenge scheme is chosen from the request alone before any token is verified: a conversation path or an
`Authorization: DPoP` header means `DPoP`; a Bearer token whose unverified header `typ` or `act` claims say Poppy Session
also means `DPoP`; everything else is `Bearer`.

The Worker boundary finishes every response with status 400 or more: it sets `Content-Type: application/problem+json`
(the Fetch adapter overwrites any earlier value while serializing JSON, so only the boundary can keep it), `Cache-Control:
no-store`, and `instance` when the error never passed the interceptor (decoding failures and the plugin's size limit). It
also writes `X-Request-Id` on every response, and turns a successful `GET` whose `ETag` matches `If-None-Match` into a 304
(`withConditionalGet`), because oRPC middleware can set headers but not a status.

## Unauthenticated and special routes

| Route                                                                                                           | Authentication                                                                                |
| --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `GET /health`, `GET /openapi.json`, `GET /poppy/openapi.json`                                                   | None. The documents are cached for 300 seconds                                                |
| `POST /webhooks/stripe`                                                                                         | Stripe's signature over the raw body (octet-stream body hint); never a bearer                 |
| `POST /internal/account/security-intents`                                                                       | The Auth0 Action's shared secret, an 8 KiB bounded body, not exposed by the webshop proxy     |
| `POST /internal/poppy/sessions/signed-out`, `/internal/poppy/agents/connected`, `/internal/agent-authorization` | The Auth0 proxy's `X-Proxy-Auth0-Secret`                                                      |
| `POST /internal/conversations/{id}/turn`, `/handoff-timeout`                                                    | The ConversationHub's `CONVERSATION_HUB_SECRET` bearer                                        |
| `POST /internal/account/recover`                                                                                | The operator's `ACCOUNT_RECOVERY_SECRET`; closed when it is not configured                    |
| `/poppy/conversations...`                                                                                       | DPoP and live Session admission; see [Poppy agents](/architecture/request-flows/poppy-agents) |

Stripe answers with problems whose `error` is `not_configured` (404), `webhook_secret_missing` (503), `missing_signature`
or `invalid_signature` (400) and `processing_failed` (500). A good delivery is 200 `{ "received": true }`. The handler
settles payments or completes an originally admitted card setup before it answers. Logs hold the event type, id and
outcome only.

## URL layout

Each operation has one URL, declared with the procedure. Which caller kinds may use it is metadata enforced by `guard`,
not a path prefix, except the groups that never mix with customer traffic. Every URL is a public contract: there are no
storefront-only URLs, because capabilities only the storefront performs run in the storefront, not here.

| Prefix                                                                                | Callers                                                         |
| ------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `/v1/catalog/*`, `/v1/cart/*`, `/v1/checkouts/*`, `/v1/checkout-previews`, `/v1/me/*` | Customer, delegated, Poppy and guest where the procedure allows |
| `/v1/ops/*`                                                                           | Operator only                                                   |
| `/poppy/conversations`, `/poppy/openapi.json`                                         | Poppy agents (DPoP) and discovery                               |

Account-bound customer and agent requests require an active account. Guests and signed-out Poppy Sessions retain
only the base capabilities their caller and scopes allow; operators use separate operator authority. The storefront
sign-in that finishes a pending email change is explicitly allowed during that transition. The API has no
back-channel logout receiver; the webshop receives it (see [back-channel logout](/architecture/request-flows/back-channel-logout)).

Creates answer 201 with a `Location` where a GET URL exists. Catalog `GET`s carry a weak `ETag` and `Cache-Control:
private, no-cache`; everything else is `no-store`.
