> ## Documentation Index
> Fetch the complete documentation index at: https://flow9.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# API conventions

> Every JSON response — success or failure — is:

> **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:

```json theme={null}
{ "success": true,  "data": { … }, "meta": { "request_id": "uuid", "timestamp": "ISO 8601" } }
{ "success": false, "error": { "code": "…", "message": "…", "details": … }, "meta": { … } }
```

* 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](/api-reference/errors).

| Situation                                              | Status    | Code                                      |
| ------------------------------------------------------ | --------- | ----------------------------------------- |
| Bad input, bad cursor, unknown field                   | 400       | `VALIDATION_ERROR`                        |
| No / invalid credential                                | 401       | `AUTH_INVALID_KEY` (or `MISSING_API_KEY`) |
| Credential lacks the scope                             | 403       | `FORBIDDEN_SCOPE`                         |
| Not this tenant's, or does not exist — **same answer** | 404       | `NOT_FOUND`                               |
| Duplicate / state conflict                             | 409       | `CONFLICT` / `ALREADY_EXISTS`             |
| Rate limited                                           | 429       | `RATE_LIMIT_EXCEEDED`                     |
| Over monthly quota / no subscription                   | 429 / 402 | `QUOTA_EXCEEDED`                          |
| Anything we cannot explain                             | 500       | `INTERNAL_ERROR` (message is generic)     |

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:

```
GET /v1/webhooks?limit=25&cursor=eyJ0Ijoi…
→ { "success": true, "data": { "subscriptions": [ … ], "next_cursor": "eyJ0Ijoi…", "has_more": true } }
```

|               |                                                                                                                                                             |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `limit`       | 1–100, **clamped** not rejected. Default 25; 100 on lists that were unbounded before (notes, tasks, record activities, webhooks); 50 on webhook deliveries. |
| `cursor`      | Opaque. Echo `next_cursor` back verbatim. A cursor that does not decode is `400 VALIDATION_ERROR`, never an empty page.                                     |
| `next_cursor` | `null` on the last page — the only stop signal a client needs.                                                                                              |
| `has_more`    | `true` when `next_cursor` is non-null.                                                                                                                      |

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:

```
Deprecation: Fri, 04 Sep 2026 00:00:00 GMT
Sunset: Fri, 01 Oct 2027 00:00:00 GMT
Link: <…/docs/api/conventions.md#pagination>; rel="deprecation"; type="text/html"
```

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

`POST`s 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](/api-reference/idempotency).

## 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](/api-reference/versioning).

## 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.
