Skip to main content
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 / apiError from _shared/response.ts. Never hand-roll a Response for JSON.
  • meta.request_id is also the X-Request-Id header. Both are always present.
  • Named payloads, not bare arrays: data.leads, data.subscriptions, data.record. A bare array cannot grow a sibling field (like next_cursor) later without breaking.
  • The one exception: POST /v1/check-email also carries a top-level exists beside the envelope, for a server-side caller coded against the pre-envelope body. It is marked deprecated in the spec; new callers read data.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:
Migrate by dropping 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 are type: [T, "null"] in the spec.
  • Every operation has tags (declared at the top of the spec) and a camelCase operationId (listWebhooks, createApiKey) — the SDK turns it into a method name, so renaming one is a breaking change.
  • Read-only lookups are GET with query parameters; anything that changes state is POST/PATCH/DELETE. PATCH is a partial update; there is no PUT.

6. Auth and tenancy

  • Routes own HTTP only. Data access goes through _shared/services/* on the tenant client (createTenantClient(ctx)); route-layering.test.ts fails 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 set security: [] in the spec and are mounted before authMiddleware.

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

  1. Spec entry first (docs/api/openapi.yaml): tags, operationId, params via #/components/parameters/PageLimit / PageCursor if it lists, envelope response, shared error responses. npm run docs:api:lint.
  2. Service function on the tenant client; pure decision logic in a Deno-free module with a vitest test.
  3. Route: validate, call the service, apiSuccess/toApiError. Nothing else.
  4. npm run contract:test — spec ↔ routes, schema drift, error catalogue.
  5. Copy the spec to public/openapi.yaml; bump sdk-gen if endpoints were added.