Surplus logo
Surplus Docsby Sharing Excess

API and contracts

Surplus has one API, and it's defined contract-first. Before any handler is written, the endpoint exists as a typed entry in the shared contract. That contract is then implemented by the server, consumed by the client, and published as an OpenAPI spec — all from the same definition.

The contract is the source of truth

Every endpoint is declared in packages/contracts with its method, path, and Zod schemas:

// packages/contracts/src/hubs.ts
list: oc
  .route({ method: 'GET', path: '/hubs' })
  .input(paginatedRequest(hubsFilterSchema))
  .output(paginatedResponse(hubListSchema)),

Because both the client and the server import this same contract:

  • The server implements it as a route handler and is type-checked against the declared input and output.
  • The client gets a fully typed caller for free — wrong inputs or mishandled outputs fail at compile time.

There is no hand-written REST layer and no manually maintained client SDK. If the contract changes, both sides know immediately.

Calling the API from the client

apps/client/src/helpers/orpc.ts wraps the contract in an ORPC client and TanStack Query utilities, exposing a typed orpc object:

// read
const { data } = useSuspenseQuery(orpc.hubs.list.queryOptions({ input }))

// write
const mutation = useMutation(orpc.routes.create.mutationOptions())

Every request includes credentials (the session cookie) and an x-request-id for correlation. See Frontend architecture for the full data-fetching story.

The public OpenAPI spec

The server generates an OpenAPI 3 document from the same contract using ORPC's OpenAPI generator. Two endpoints expose it:

  • /openapi.json — the raw OpenAPI spec.
  • /openapi — an interactive Scalar reference UI for browsing and trying endpoints.

You can reach the hosted reference from the OpenAPI Reference link in this site's sidebar. Because the spec is generated, it never drifts from the real API — it is the contract, rendered.

Adding an endpoint

Adding an endpoint means touching the four layers in order (the full walkthrough is in Request lifecycle):

  1. Contract — declare the route, input, and output in packages/contracts.
  2. Service function — write the domain logic in packages/services.
  3. Service class — expose it as a transactional method.
  4. Route handler — implement the contract in apps/server/src/routes, apply authMiddleware, and register it in helpers/orpc.ts.

Reuse schemas from @surplus/types rather than redefining fields — derive new shapes with .pick(), .omit(), .partial(), and .extend().

Validation and errors

  • Input and output are validated by Zod at the contract boundary, so handlers receive well-formed data and clients receive well-formed responses.
  • Errors are typed in @surplus/services and mapped to the right HTTP status by an interceptor, which also attaches a requestId to the error payload for support and debugging.

Authentication and access control

The API authenticates requests with a session cookie and authorizes them with role-based middleware. Those mechanics belong to security, and are documented in Authentication and Authorization.