Skip to Content
API (apps/api)Adding an Endpoint

How to add an API endpoint

This is the workflow for adding a new HTTP endpoint to apps/api, from wire contract to passing CI gates. The order matters: the contract comes first, because both the portal client and the Lambda handler are typed against it — defining the shape once in a shared package is why a schema change surfaces as a typecheck error on both sides instead of a runtime decode failure in production.

Define (or extend) the contract package

Request/response schemas live in a shared contract package under packages/ (e.g. packages/kpi-api-contract), written in Effect Schema. Both apps/api and the portal import from here — never define wire shapes inline in a handler.

Declare the endpoint in a group

Add an HttpApiEndpoint to the relevant apps/api/src/groups/<name>.ts. Use branded route-ref schemas for path params so a malformed or wrong-kind ref fails at decode, before any DB access:

// apps/api/src/groups/kpi.ts (abridged) const CompanyPathParams = Schema.Struct({ companyRef: CompanyRefSchema });

Attach the group’s generated failure-protocol errors (KpiApiUnauthorized, KpiApiNotFound, … from ../failure-protocol/group-protocol.generated) and the auth middleware tag the group requires.

Implement the handler

In apps/api/src/handlers/<name>.ts, implement it as part of the HttpApiBuilder.group(api, "name", handlers) layer. Handlers receive typed, decoded input and depend on persistence through an explicit deps interface (see KpiHandlerDeps) — keep DB access behind /persistence repositories.

Wire (or create) the entry

If the endpoint belongs to an existing Lambda, add the group/handler to its entries/<name>.ts. If it’s a new Lambda, create a new entry that builds its own HttpApi.make(...), then:

const appLayer = makeAppLayer(withApiFailureProtocol(MyApi), { handlers: [myHandlersLayer], middleware: [ makeObservedApiFailureGroupRuntimeLayer('my-group', { servedContract: 'envelope', observationProfile: 'terminal', }), MyAuthMiddlewareLayer, ], }); export const handler = toLambdaHandler(appLayer);

A new entry also needs a Lambda definition in the CDK AppApiStack — see /infra for route wiring, env vars, and IAM.

Choose the auth seam

Every endpoint must be covered by exactly one middleware from src/middleware/:

  • workos-jwt.ts — browser/session traffic (portal Worker forwards the WorkOS JWT).
  • api-token.ts — non-interactive access (MCP, Excel Power Query) via api_tokens.
  • plaid-jws.ts — Plaid webhook signature verification.

Auth is deliberately a middleware seam rather than an API Gateway JWT authorizer, so one Lambda family can host multiple schemes (rationale in middleware/workos-jwt.ts).

Run the gates

bun run verify

The endpoint-relevant gates inside verify:

  • check:authz-endpoint-coverage — an endpoint with no authorization coverage fails verify.
  • check:lambda-budgets — the new/changed entry’s bundle must stay inside its size budget.
  • check:api-failure-serving-gate / check:api-failure-no-new-debt — the group must serve the ADR-0041 failure envelope without adding untyped failure debt.

Common pitfalls

  • Missing env at cold start. Config readers throw at module/first-use time — e.g. readRdsAppEnv() throws if RDS_PROXY_ENDPOINT is unset, and entries/kpi.ts throws if PORTAL_ENVIRONMENT is unset. Set every env var in AppApiStack when you add the Lambda.
  • Merging middleware and handlers as siblings. makeAppLayer applies handler layers then middleware layers as sequential Layer.provides; a flat merge yields Service not found: <middleware tag> at route build. Use makeAppLayer — don’t hand-roll the composition.
  • Query array params. One value decodes as a string, many as an array — accept Schema.Union([Schema.String, Schema.Array(Schema.String)]) and normalize (see groups/kpi.ts).
  • Per-package typecheck is not enough. Only bun run verify catches cross-package inference and the gates above.
Last updated on