---
title: How the API is built on oRPC
description: The contract, the implementation, the context builders, the error encoding and the two OpenAPI views.
sidebar:
  order: 12
---

The whole Shop API runs on oRPC `2.0.0-beta.43` (v2, pinned exactly). The webshop, MCP server and backoffice call it
through the `@retail-demo/api` client. Spikes run before the migration rejected two error-transport assumptions; both
corrections are part of the build and have regression tests (see [Error-transport corrections](#error-transport-corrections)).

## Layout

```text
apps/api/                    @retail-demo/api (exports only ./contracts* and ./client*)
  contracts/                 public, browser-safe: meta, base, router, procedures/*, schemas, errors, callers, scopes
  contracts/minified.json    minifyRouterContract(contract): routes only, for clients that skip the schemas
  client/                    createShopApi, problemOf, TimeoutError (server-side callers only)
  src/
    index.ts handler.ts      Worker fetch (handleRequest) and the OpenAPIHandler
    router.ts shop-router.ts the implementation: shop-router mirrors contracts/router.ts, router adds server-only procedures
    context/                 initial context, isolate memo, domain context, builders, stages
    middleware/              withRequest ... rate limits, shared secrets, catalog cache, conditional GET
    errors/                  toORPCError, encodeProblem, problem media type
    procedures/              one handler file per contract file
    conversations/ system/   Poppy conversations, health, documents, Stripe, internal callbacks
    openapi/                 generator, decorate, the two views
  workers/ worker.ts         Durable Objects and the scheduled export
```

## Add a procedure

1. **Contract.** In `contracts/procedures/<family>.ts`, a plain `oc` chain: `base.meta(openapi({ method, path,
operationId, tags, summary, successStatus })).meta(api({ callers, apiScopes, agentScopes
})).errors({...}).input(z.object({...})).output(Schema)`. Use a named caller list from `contracts/callers.ts`. The
   `operationId` names the OpenAPI operation, rate-limit key and log
   `operation`. Existing MCP and WebMCP operation tool names match it but are defined independently. An input is one flat `z.object`; put headers or a raw body in a detailed input
   only when they are real inputs. A request field must not be called `operationId`: name the fresh intent
   `freshIntentId`.
2. **Router.** Add it to `contracts/router.ts` (the nesting is the client's call path, `client.cart.items.add`) and mirror
   it in `src/shop-router.ts`. The implementer refuses a router that differs.
3. **Handler.** In `src/procedures/<family>.ts`, `export const addCartItem = v1.cart.items.add.handler(({ context, input })
=> ...)`, with the same name as the contract export. Take `context.ctx` (domain), `context.actor`, `context.config`,
   `context.resHeaders`. Throw `ShopError`, `AccessError` or `RequestError`; never build a response. Set `Location` on a
   201 that has a GET URL. Rate limits, caller and scope guards and the status come from middleware and
   metadata.
4. **Tests.** A procedure test beside the handler uses `testApp()` from `apps/api/tests/support/app.ts`; contract tests
   (`router.test.ts`, `router.openapi.test.ts`) pin the shape, and `src/openapi/documents.test.ts` regenerates both
   OpenAPI snapshots with `WRITE_CONTRACT_FIXTURES=1` (review the diff).
5. **Consumers.** The webshop calls `callAsCustomer(actor, (api, options) => api.cart.items.add(input, options))`. A
   tool is added explicitly to `apps/mcp/src/tools/` or the storefront's `src/features/webmcp/tools/`, with its own
   schemas, presentation and typed API call. Each surface has a registry in `tools/index.ts`; API metadata does not
   register tools. Regenerate `contracts/minified.json` with `WRITE_CONTRACT_FIXTURES=1` (`router.minified.test.ts`)
   when API routing changes. The storefront uses that compact routing contract for API calls and sends only plain
   WebMCP descriptors to the browser. `tools.snapshot.test.ts` pins those descriptors independently of the API.

## Context without a dependency bag

The initial context is `{ env, request, waitUntil, record, ports? }` plus the `resHeaders` the response-headers plugin
adds. `env` is Cloudflare's bindings object, never copied; `record` carries the request id and what the guard learns for
the completion log; `ports` is test-only (`fetch`, `now`, `logSink`, and `providers` for `stripe`, `managementApi`,
`hub`, `opsManagement`). Long-lived objects (verifiers, the Auth0 proxy client, the DPoP stores, the Stripe client, the
database handle and the generated OpenAPI documents) are memoized once per isolate by `once(env, key, create)` in
`context/isolate.ts`, keyed by the bindings object and a closed set of keys. Configuration is parsed once per isolate.

Typed stages (`context/stages.ts`) are added by the middleware: `RequestStage`, `DomainStage`, `AuthenticatedStage`,
`AdmittedStage`, `ActorStage`. `context/builders.ts` composes them:

| Builder             | Chain                                                                                             | Used for                                                                       |
| ------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `v1`                | withRequest, withDomain, authenticate, admitCaller, withActor, guard, rateLimit (on the contract) | every `/v1` procedure                                                          |
| `pub`               | withRequest, withDomain                                                                           | health, the OpenAPI documents and the Stripe webhook                           |
| `poppyConversation` | pub, authenticate, admitCaller, conversation actor, direct-Poppy-caller check, rateLimit          | `/poppy/conversations`                                                         |
| `secret(name)`      | withRequest, shared secret, withDomain                                                            | internal callbacks (`ACTION`, `PROXY_AUTH0_NOTIFY`, `HUB`, `ACCOUNT_RECOVERY`) |
| `guarded`           | the `v1` chain over a plain builder                                                               | tests that need procedures of their own                                        |

`shop` is the implementer without middleware; the router is built from it so each stage runs once for a procedure that
already went through `v1`. Domain code, the Durable Objects and the scheduled export build the same domain context with
`domainContext({ env, ports, fields })`.

Tests inject fakes through the initial context: `testApp({ env, fetch, logSink, now })` returns `{ app, env, ports, ctx,
raw, settle }`, and `app.request(path, init)` goes through `handleRequest`, the entry the Worker uses. Set `env` before
the first request, because configuration is parsed once per `env` object.

## Errors and the boundary

[The API pipeline](/architecture/api-pipeline#one-error-encoding) describes the encoding. In short: a client interceptor
runs `toORPCError`, `encodeProblem` writes the body, and the Worker boundary (`handleRequest`) sets
`application/problem+json`, `Cache-Control: no-store`, the `X-Request-Id` and, when the error never passed the interceptor,
`instance`. Domain errors may carry only `Retry-After`, `RateLimit` and `RateLimit-Policy`.

## OpenAPI views

`src/openapi/documents.ts` generates two OpenAPI 3.1.1 documents from the one contract with a private schema registry:
`apiDocument` (all procedures, required `bearer` security with each procedure's API scopes)
and `poppyDocument` (procedures whose callers include `poppy`, `dpop` security with the account scopes, `x-poppy-scopes`).
`decorate` then applies, per operation and from the procedure metadata, the security requirement, the problem media type
on every failure, `WWW-Authenticate` on 401 and 403, `Retry-After` on 429 and 503, and `Location` on 201. It throws on an operation it can't map. The Worker
serves them at `GET /openapi.json` and `GET /poppy/openapi.json` (cached 300 seconds); `documents.test.ts` checks every
operation of each view. Server-only procedures (conversations, webhooks, internal callbacks, health) are not in the
contract and so not in the documents.

A guest has no personal user token, but the storefront still authenticates every guest API request with its machine
token. Cart authority additionally comes from the signed `Guest-Session` capability. Guest-capable operations never
advertise anonymous API access: tokenless calls receive 401 before the procedure runs.

## Implementation notes

These choices differ from what oRPC or the original migration design suggested:

- `withRequest` writes `X-Request-Id` and a valid `traceparent`, and the boundary writes `X-Request-Id` again on every
  response and finishes errors, so a failure before any middleware still has the header and an `instance`.
- Conversation writes over 64 KiB are refused by a `Content-Length` check; the plugin's global limit is 256 KiB, because
  its option is a single number.
- oRPC reads a body without `Content-Type` as text, so clients that write JSON must send `Content-Type: application/json`.
- A matching `If-None-Match` on a catalog read becomes a 304 at the boundary (`withConditionalGet`): oRPC middleware can
  set headers but not a status.
- The router is built from the middleware-free `shop` implementer; building it from `v1` ran every stage twice.
- Hub callbacks are `/internal/conversations/{id}/turn` and `/handoff-timeout`.
- `text-delta` events are not sent (the event iterator writes only `message`, `error` and `close`), and a reader that
  loses access is closed without an `event: error`.
- A bodiless `DELETE` input uses `z.object({}).default({})`, because oRPC otherwise validates `undefined`.

## Error-transport corrections

1. `Content-Type: application/problem+json` is set on the Fetch `Response` after `OpenAPIHandler.handle()` returns. A
   routing interceptor is not enough: the Fetch adapter's `toFetchBody` sets `application/json` while serializing an
   ordinary object. `src/index.test.ts` keeps the routing-only regression next to the corrected behavior.
2. The client forces JSON body resolution for problem responses (`problemBodyAsJson`), because the pinned body parser
   recognizes `application/json` but not the `+json` suffix, and oRPC resolves the body before its error decoder runs.
   Without it a problem becomes `MALFORMED_ORPC_RESPONSE`; `client/index.test.ts` keeps that unhinted regression. A
   contract-level `responseBodyHint: 'json'` was rejected because it also applies to success bodies, including the
   JSON-or-event-stream route.

`@standard-server/core` and `@standard-server/fetch` are pinned to 0.10.2 by the overrides in `pnpm-workspace.yaml`;
oRPC's own ranges allow later patches, so rerun both regressions when changing them.

The spikes that found these (15 October 2026) also showed that router-level middleware runs before input validation,
that the `octet-stream` body hint keeps a webhook body byte-exact, that the native event iterator gives conforming SSE
framing with a data-less `event: close`, and that private-registry OpenAPI 3.1.1 generation validates against the
official schema. They measured the oRPC client at about 21 KB gzip added to the webshop's server build; that small
sample is not a measurement of the migrated API or its consumers. The spike code and its report are in Git history:
`git show 062a656 -- scripts/spikes/orpc` and `git show 58bb1646:docs/research/2026-10-15-orpc-spikes.md`. The
completed migration plan is at `git show 0deb08b6:docs/plans/2026-10-15-orpc.md`.
