Skip to content
Knowledge sections

REST API

Effect HttpApi exposed by the API Worker, generated OpenAPI, and the error model.

restopenapihttp
On this page

The REST API is one of two Capability Interfaces over @b2b-saas-starter/capabilities. The contracts are defined with Effect HttpApi in packages/api, served by the API Worker in apps/api, and documented through a generated OpenAPI document plus a Scalar reference UI.

Endpoint shape

HttpApiGroup definitions in packages/api/src/index.ts declare each endpoint once with Effect Schema, and the wire path is workspace-slug scoped (no /v1 prefix):

HttpApiGroup.make('workspace').add(
  HttpApiEndpoint.get('overview', '/workspaces/:slug/overview', {
    params: SlugParams,
    success: WorkspaceOverviewDto,
    error: [WorkspaceNotFound, InternalError, Unauthorized, RateLimited]
  })
)

The same schemas feed the OpenAPI document. A typed client can be generated from the same HttpApi instance once the web Worker is ready to swap server functions for an API-backed reader.

Routing

apps/api serves the HttpApi groups directly: HttpApiBuilder.layer(StarterApi) plus per-group handler layers, converted to a Cloudflare web handler with HttpRouter.toWebHandler (apps/api/src/http.ts). Paths, params, payloads, success/error schemas, and status codes are all the contract's: there is no separate route table to drift from the OpenAPI document (ADR-0039). Hono is intentionally avoided.

Authentication

Public REST clients authenticate with a workspace-scoped API Token in the Authorization: Bearer … header. The worker calls ApiTokenRegistry.verifyBearerToken(token) and then checks the route's permission against the token's scopes: a missing header or an unknown token returns HTTP 401, a token without the permission returns HTTP 403. Browser sessions are not accepted on the public API surface; cookies are an internal contract for the web Worker only.

Pagination

Every list endpoint (members, notifications, api-tokens, webhooks, audit-events) is a paged read (ADR 0057). It accepts two optional query parameters and answers a Page: items plus an opaque nextCursor that is null on the last page.

GET /workspaces/acme/notifications?limit=2
 
{
  "items": [ … ],
  "nextCursor": "MjAyNi0wNS0xNlQwODoxMDowMC4wMDBaIG5vdF9lbWFpbA"
}

limit defaults to 50 and is clamped into [1, 200]: out-of-range values narrow or widen to the range instead of failing. The cursor is a keyset position (the sort key of the last item plus its id, base64-encoded): a page returns rows strictly past that position in the endpoint's order, so rows inserted between two fetches never shift, duplicate, or hide rows the caller has not yet seen. Timestamped lists (notifications, api-tokens, audit-events) read newest-first on (createdAt, id); members and webhooks carry no timestamp on the wire and read forward on id. A cursor that cannot be decoded addresses no position and yields an empty page. MCP list tools take the same two inputs. The typed SDK walks pages for you; see Typed SDK.

Rate limiting

Each route declares a Cloudflare RateLimit bucket (rest_read, rest_write, assistant, mcp), and the worker calls binding.limit({ key: clientKey(request) }) before running the handler, falling back to an in-isolate brake when the bindings are absent.

Error model

Tagged errors like WorkspaceNotFound, Unauthorized, RateLimited, and InternalError carry an httpApiStatus annotation so they map to HTTP status codes through one mapping. MCP uses the same guard status mapping at its transport gate and returns expected operation refusals as tool errors. Adding a new domain error in @b2b-saas-starter/capabilities updates both interfaces at type-check time.

OpenAPI and Scalar

Hitting https://api.<domain>/reference returns the Scalar reference UI. The raw OpenAPI document is at /openapi.json and regenerates on every deploy from the HttpApi groups in packages/api.

MCP parity

Every supported workspace mutation is also available as an authorized MCP tool. REST response contracts remain unchanged, including webhook creation returning endpoint metadata without its signing secret. Rotate the secret to obtain one. Token creation checks that the requested scopes grant no permission beyond the caller's authority. See MCP server for write tools, side effects, and one-time secret responses.