API Conventions
Cross-feature contract for the Core API. Every endpoint built in Layer 2 and above follows this. Decided once here; the helpers in internal/shared/apiquery and internal/core/idempotency enforce the patterns at the code level.
For error responses see error-envelope.md.
Versioning
- All endpoints live under
/v1/. Major version in the path; minor versions are additive (new fields, new endpoints, new optional query params). - A breaking change ships as
/v2/...while/v1/keeps running. We do not break paths in place. - Public, unauthenticated endpoints (e.g. domain resolve) live under
/v1/public/.... They run on AdminPool with tightly-scoped handler queries (single-row equality match, allow-listed columns) — no RLS public carve-outs.
Pagination
GET /v1/things?page=2&limit=50| Param | Default | Bounds | Notes |
|---|---|---|---|
page | 1 | ≥ 1 | 1-indexed. Out-of-range values clamp to 1. |
limit | 50 | 1 – 500 | >500 is silently clamped to 500. |
Response shape:
{
"data": [ ... ],
"pagination": { "page": 2, "limit": 50, "total": 1234 }
}Helper: apiquery.ParsePage(r) returns Page{Page, Limit, Offset}. Offset = (Page - 1) * Limit. Pass directly to a repository:
page := apiquery.ParsePage(r)
items, total, err := repo.List(ctx, page.Limit, page.Offset, ...)
if err != nil { httputil.HandleError(w, err); return }
httputil.JSON(w, http.StatusOK, apiquery.NewEnvelope(items, page, total))total is the unfiltered (well, filtered-by-query but ignoring pagination) row count. Repositories typically compute it with a parallel SELECT COUNT(*) against the same WHERE clause. For very large tables this can be relaxed to an estimate — open decision per feature.
Sorting
GET /v1/things?sort=name,-created_at- Comma-separated list of fields, in priority order.
-prefix on a field means descending; absent prefix means ascending.- Each field must be on the handler's allow-list (
apiquery.ParseSort(r, []string{...})). Unknown fields return 422 with afields.sortreason.
Helper signature:
allowed := []string{"name", "created_at", "updated_at"}
orders, err := apiquery.ParseSort(r, allowed)
if err != nil { httputil.HandleError(w, err); return }
orderBy := apiquery.SQL(orders) // e.g. "name ASC, created_at DESC"Handlers fall back to a default ORDER BY (typically created_at DESC) when orders is nil — a no-sort request gets stable, newest-first results, not a planner-dependent order.
Picker endpoints (typeahead / autocomplete)
GET /v1/users?q=alice&limit=20
GET /v1/users?ids=550e8400-...,7c9e6679-...A picker endpoint serves a typeahead UI — return the top-N matches for an in-flight search, or resolve a fixed set of IDs back to display labels. It is not the same shape as a paginated directory listing; pickers don't paginate (refining the query is the navigation). When a feature needs both — a directory page and a picker UI — expose them as two endpoints (ListPaginated and ListPicker in the repo, two routes / one route with mode-detection in the handler).
| Param | Default | Bounds | Notes |
|---|---|---|---|
q | "" | trimmed | Substring search on a handler-chosen column (email, name, slug). Empty = no filter. |
ids | nil | comma-separated UUIDs | Resolve specific rows. Invalid UUIDs are silently dropped. Non-empty ids takes precedence over q. |
limit | 20 | 1 – 50 | >50 is silently clamped. There is no "return everything" mode (CLAUDE.md → Production Scale). |
Three modes (caller-chosen via params):
- By IDs:
?ids=...— used by client pages to resolve labels for items already selected via URL filter params at first paint, before the picker popover opens. - Substring search:
?q=alice— typeahead's primary mode. Order by relevance signals (recent activity, alphabetical) at the repo's discretion. - Default listing: neither
qnorids— "popover just opened, no input yet" affordance. Order is repo-chosen (typically most recent first).
Helper: apiquery.ParsePicker(r) returns PickerParams{Query, IDs, Limit} with defaults applied and Limit clamped. Pass straight to a repository's ListPicker method:
picker := apiquery.ParsePicker(r)
rows, err := h.service.ListPicker(r.Context(), thing.ListPickerParams{
Query: picker.Query,
IDs: picker.IDs,
Limit: picker.Limit,
})Response shape is the standard list envelope ({ "data": [...] }) — pickers don't include a pagination block since they don't paginate.
apiquery.ParseUUIDList(raw) is the underlying CSV-of-UUIDs parser. Use it directly when a non-picker handler takes a UUID-list filter (e.g. audit log's ?organization_id=...&user_id=...).
Filtering
Flat query params:
GET /v1/appointments?status=scheduled&specialist_id=8a7f...- Each filter is a top-level query-string key.
- No nested syntax (
?filter[status]=...), no DSL, no operators in keys (?status_eq=...). - Handlers read the keys they support directly via
r.URL.Query().Get(...). Unknown keys are ignored — clients see no error for typos. (This is a pragmatic call: strict mode, where unknown keys 4xx, blocks adding params later without coordinated rollouts. Layer 12 OpenAPI may revisit.) - Range queries use a dedicated key per direction:
?created_after=...,?created_before=.... Don't put operators inside values. See "Date ranges" below for the parser.
When a feature truly needs complex filtering (boolean composition, OR groups, nested fields), build a domain-specific filter struct or a small DSL — that's a per-feature decision, not a platform convention.
Date ranges
GET /v1/audit-logs?created_after=2026-01-01&created_before=2026-04-30
GET /v1/appointments?starts_after=2026-04-30T15:00:00ZA date range is a pair of query-string keys — <col>_after and <col>_before — bounding a timestamp column. Each side is optional; specifying only one produces an open interval.
| Param | Format | Snapping | Notes |
|---|---|---|---|
<col>_after | YYYY-MM-DD or RFC3339 | Date-only → 00:00:00.000 UTC | Inclusive lower bound. |
<col>_before | YYYY-MM-DD or RFC3339 | Date-only → 23:59:59.999999999 UTC | Inclusive upper bound. |
Both sides accept either a date (the format the typical date-range picker emits) or a full RFC3339 timestamp (for sub-day precision: forensics, scheduled-task replays). Anything else returns 422 with a fields.<col>_after or fields.<col>_before reason — strict validation, on purpose. Unlike unknown enum / sort / picker values (which the platform drops silently), a malformed timestamp would silently change the result set by producing a different bound than intended. We refuse the request instead.
Helper: apiquery.ParseDateRange(r, prefix) returns DateRange{After, Before *time.Time}.
created, err := apiquery.ParseDateRange(r, "created")
if err != nil { httputil.HandleError(w, err); return }
params.CreatedAfter = created.After // *time.Time, nil if absent
params.CreatedBefore = created.BeforePer-column: a feature with multiple timestamp columns exposes multiple range pairs (?starts_after=&starts_before=, ?created_after=&created_before=), each parsed independently. Repos turn nil bounds into "no clause" (if p.CreatedAfter != nil { ... created_at >= $N }).
Idempotency
POST /v1/things
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000- Optional header on POST endpoints that create resources or trigger external side effects (charges, emails, third-party API calls). Layer 6 will likely flip this to required for financial operations.
- Format: 4–128 ASCII characters,
[A-Za-z0-9_-]. UUIDs are the convention. - Server-side TTL: 24 hours. Cache key:
idempotency:{org_id}:{path}:{key}. - Only 2xx responses are cached. A request that errored returns the error and is not cached, so the client can retry and have the eventual success persisted.
- A replayed request returns the original response byte-for-byte plus an
Idempotency-Replayed: trueresponse header.
Wiring (per route, never global):
idempotent := idempotency.Middleware(idempotencyStore, 0) // 0 = use default 24h TTL
r.With(idempotent).Post("/v1/things", thingHandler.Create)Storage failures (Redis down, corrupted entry) never block the request — the handler runs and the response is returned normally; the storage error is logged. The contract is "an already-successful request is not re-executed when the same key is replayed against a healthy cache," not "Redis must be up for POSTs to work."
Rate limiting
Selected unauthenticated routes are rate-limited per IP — today, the public org-resolve endpoint and the entry to /v1 (capping JWT-verification cost). Per-tenant per-endpoint limiting on authenticated routes lands in Layer 12.
Every rate-limited response — allowed or denied — carries the canonical headers:
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 12
X-RateLimit-Reset: 1745930400A 429 response additionally carries Retry-After: <seconds> and a rate_limited JSON envelope. See error-envelope.md § Rate-limited errors for the full shape.
Wiring (per route, never global):
publicResolvePolicy := ratelimit.Policy{
Code: "public_resolve",
Limit: cfg.RateLimitPublicResolveLimit, // RATELIMIT_PUBLIC_RESOLVE_LIMIT, default 30
Window: cfg.RateLimitPublicResolveWindow, // RATELIMIT_PUBLIC_RESOLVE_WINDOW, default 1m
}
r.With(ratelimit.Middleware(store, publicResolvePolicy, ratelimit.IPKey)).
Get("/organizations/resolve", h.HandleResolve)Storage failures fail open — if Redis is unreachable, requests proceed and the error is logged. Failing closed on transient Redis hiccups would expose a much larger DoS surface than the limit prevents.
OperationId naming (D-9)
Settled in foundation 1D-prep, decision D-9 (apps/docs/implementation-plan/1d-ui-inventory.md). The OpenAPI operationId for every operation matches the api-client wrapper name in packages/api-client/src/client.ts — concise and domain-scoped, never …Organization… redundancy nor …Current… self-context noise.
Examples:
| OperationId | Wraps |
|---|---|
getMe | GET /v1/me |
updateMe | PATCH /v1/me |
addDomain | POST /v1/organizations/{id}/domains |
verifyDomain | POST /v1/organizations/{id}/domains/{domainId}/verify |
getOrgSettings | GET /v1/organizations/{id}/settings |
onboardPatient | POST /v1/portal/onboard |
addMember | POST /v1/organizations/{id}/members |
listMyClinics | GET /v1/me/clinics (D-8) |
Rules:
- No
…Organization…redundancy on per-org routes. The path already says/v1/organizations/{id}/…; the operationId saysaddDomain, notaddOrganizationDomain. - No
…Current…self-context noise./v1/me/…operationIds drop theCurrent:getMe, notgetCurrentUser. Orgshorthand for org-scoped sibling resources that aren't the org itself:getOrgSettings,getOrgBilling,getOrgEntitlements,listOrgSubscriptions. Reads better thanOrganizationSettingsfour words deep.My…for cross-org self-context only (noX-Organization-IDrequired):listMyClinics,listMyConsents,listMyPendingInvitations,listMyPatientImpersonationSessions. TheMyprefix marks the cross-org Account-surface scope.- Verbs match the HTTP method's intent.
POSTthat creates a resource iscreate…,mint…,open…, orgrant…;POSTthat runs a side-effect on an existing resource (/test,/revoke,/close,/regenerate-secret) keeps the action name as the verb (testIntegration,revokeShareLink).
When adding a new endpoint:
- Pick the operationId following the rules above.
- Add the wrapper in
packages/api-client/src/client.tswith the same name. - Run
pnpm openapi+cd services/api && make openapito regenerate types.
Drift between spec and client wrapper is a code-review issue.
What's NOT in this convention
- Long polling / Server-Sent Events — when a feature needs real-time, decide per feature. We don't have a platform-wide pattern.
- Bulk operations —
POST /v1/thingscreates one thing;POST /v1/things/batchis a per-feature decision when (and only if) the feature needs it. - Partial updates — convention is
PATCHwith the changed fields only. JSON Patch / JSON Merge Patch is overkill for the surface we have today; revisit if a feature spec demands it. - Webhooks — outbound webhooks (P29) have their own retry, signature, and replay rules, separate from this client-facing surface.
- OpenAPI specification — lives at
apps/docs/openapi.yaml; spec-first contract. The shapes here (envelope, pagination, sort, idempotency) all appear in the spec; this doc describes the rules, the spec describes the wire format. Runmake openapi(Go types) andpnpm openapi(TypeScript types) after editing the spec; the drift test atinternal/core/server/openapi/spec_test.gofails ifroutes.go, the spec, and the test'sexpectedRoutestable go out of sync.
Code locations
- Pagination + sort helpers:
services/api/internal/shared/apiquery/ - Idempotency middleware:
services/api/internal/core/idempotency/ - Error envelope:
services/api/internal/shared/httputil/(see error-envelope.md)