Read this before adding or changing a /v1 route. These are the rules the
Headless CRM epics (HL-A/B/C) build on. They are enforced where a test can enforce
them; the rest is review. converged the surface onto them.
1. One envelope
Every JSON response — success or failure — is:- Build it with
apiSuccess/apiErrorfrom_shared/response.ts. Never hand-roll aResponsefor JSON. meta.request_idis also theX-Request-Idheader. Both are always present.- Named payloads, not bare arrays:
data.leads,data.subscriptions,data.record. A bare array cannot grow a sibling field (likenext_cursor) later without breaking. - The one exception:
POST /v1/check-emailalso carries a top-levelexistsbeside the envelope, for a server-side caller coded against the pre-envelope body. It is markeddeprecatedin the spec; new callers readdata.exists. Do not add a second exception.
2. Errors
error.code is the contract — branch on it, never on message. The catalogue is
ErrorCodes in _shared/errors.ts, mirrored by the ErrorCode enum in the spec and
locked by error-catalogue.test.ts. Full list and meanings: errors.md.
A cross-tenant id is a 404, never a 403: a distinct answer confirms the id exists.
3. Pagination
Cursor pagination, everywhere. Every list endpoint accepts the same two query parameters and returns the same two fields next to its named array:
Why keyset and not offset: with
OFFSET n, one insert between two pages repeats a
row and one delete skips one — silently, in a sync integration. A cursor anchors on
a row, not a position. The sort is total, (timestamp, id) DESC, so a page boundary
between two same-second rows can neither repeat nor skip. Design notes are in
_shared/cursor.ts; the shared reader is _shared/pagination.ts (resolvePage).
Implementing a new list: resolvePage(query) → .or(keysetFilter(position))
when a cursor was sent → order by the timestamp then id, both descending →
.limit(limit + 1) → buildPage / buildPageBy. Lists assembled from more than
one table page in memory with pageInMemory, same semantics.
The deprecated offset path
GET /v1/activities shipped with limit/offset and a pagination.total. Both
still work (additive-only), and the response now also carries next_cursor /
has_more. A request that sends offset gets the headers:
offset and passing next_cursor as cursor. Nothing else
in v1 paginates by offset; do not add anything that does.
4. Idempotency
POSTs that create take an Idempotency-Key header (8–255 chars). Same key + same
body replays the original response with Idempotent-Replay: true; same key +
different body is 422 IDEMPOTENCY_CONFLICT. Keys are scoped to the company and
the endpoint and expire after 24 h. Use idempotencyGuard(required) on the route.
Detail: idempotency.md.
5. Naming and shape
- Paths: plural nouns, kebab-case (
/v1/activity-types,/v1/webhook-events); sub-resources nest under their parent id; non-CRUD actions are a trailing verb (/rotate-secret,/replay,/test) or a colon suffix (/records:batch). - Fields:
snake_case. Timestamps are ISO 8601 UTC strings; money is minor units (price_monthly_minor); nullable fields aretype: [T, "null"]in the spec. - Every operation has
tags(declared at the top of the spec) and a camelCaseoperationId(listWebhooks,createApiKey) — the SDK turns it into a method name, so renaming one is a breaking change. - Read-only lookups are
GETwith query parameters; anything that changes state isPOST/PATCH/DELETE.PATCHis a partial update; there is noPUT.
6. Auth and tenancy
- Routes own HTTP only. Data access goes through
_shared/services/*on the tenant client (createTenantClient(ctx));route-layering.test.tsfails a route that imports the admin client or calls.from(). - Scope-gate with
scopeGuard/entitlementGuard(or inside the service where the 403/404 distinction matters). Public (credential-less) routes setsecurity: []in the spec and are mounted beforeauthMiddleware.
7. Versioning
Additive only within/v1: add fields and endpoints; never remove, rename, retype,
or make optional things required. Deprecate with deprecationHeaders() and a
≥ 12-month sunset — versioning-policy.md.
Checklist for a new endpoint
- Spec entry first (
docs/api/openapi.yaml): tags, operationId, params via#/components/parameters/PageLimit/PageCursorif it lists, envelope response, shared error responses.npm run docs:api:lint. - Service function on the tenant client; pure decision logic in a Deno-free module with a vitest test.
- Route: validate, call the service,
apiSuccess/toApiError. Nothing else. npm run contract:test— spec ↔ routes, schema drift, error catalogue.- Copy the spec to
public/openapi.yaml; bumpsdk-genif endpoints were added.