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) viaapi_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 verifyThe 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 ifRDS_PROXY_ENDPOINTis unset, andentries/kpi.tsthrows ifPORTAL_ENVIRONMENTis unset. Set every env var inAppApiStackwhen you add the Lambda. - Merging middleware and handlers as siblings.
makeAppLayerapplies handler layers then middleware layers as sequentialLayer.provides; a flat merge yieldsService not found: <middleware tag>at route build. UsemakeAppLayer— 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 (seegroups/kpi.ts). - Per-package typecheck is not enough. Only
bun run verifycatches cross-package inference and the gates above.