Skip to content

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
ParamDefaultBoundsNotes
page1≥ 11-indexed. Out-of-range values clamp to 1.
limit501 – 500>500 is silently clamped to 500.

Response shape:

json
{
  "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:

go
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 a fields.sort reason.

Helper signature:

go
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).

ParamDefaultBoundsNotes
q""trimmedSubstring search on a handler-chosen column (email, name, slug). Empty = no filter.
idsnilcomma-separated UUIDsResolve specific rows. Invalid UUIDs are silently dropped. Non-empty ids takes precedence over q.
limit201 – 50>50 is silently clamped. There is no "return everything" mode (CLAUDE.md → Production Scale).

Three modes (caller-chosen via params):

  1. 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.
  2. Substring search: ?q=alice — typeahead's primary mode. Order by relevance signals (recent activity, alphabetical) at the repo's discretion.
  3. Default listing: neither q nor ids — "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:

go
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:00Z

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

ParamFormatSnappingNotes
<col>_afterYYYY-MM-DD or RFC3339Date-only → 00:00:00.000 UTCInclusive lower bound.
<col>_beforeYYYY-MM-DD or RFC3339Date-only → 23:59:59.999999999 UTCInclusive 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}.

go
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.Before

Per-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: true response header.

Wiring (per route, never global):

go
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:     1745930400

A 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):

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

OperationIdWraps
getMeGET /v1/me
updateMePATCH /v1/me
addDomainPOST /v1/organizations/{id}/domains
verifyDomainPOST /v1/organizations/{id}/domains/{domainId}/verify
getOrgSettingsGET /v1/organizations/{id}/settings
onboardPatientPOST /v1/portal/onboard
addMemberPOST /v1/organizations/{id}/members
listMyClinicsGET /v1/me/clinics (D-8)

Rules:

  • No …Organization… redundancy on per-org routes. The path already says /v1/organizations/{id}/…; the operationId says addDomain, not addOrganizationDomain.
  • No …Current… self-context noise. /v1/me/… operationIds drop the Current: getMe, not getCurrentUser.
  • Org shorthand for org-scoped sibling resources that aren't the org itself: getOrgSettings, getOrgBilling, getOrgEntitlements, listOrgSubscriptions. Reads better than OrganizationSettings four words deep.
  • My… for cross-org self-context only (no X-Organization-ID required): listMyClinics, listMyConsents, listMyPendingInvitations, listMyPatientImpersonationSessions. The My prefix marks the cross-org Account-surface scope.
  • Verbs match the HTTP method's intent. POST that creates a resource is create…, mint…, open…, or grant…; POST that 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:

  1. Pick the operationId following the rules above.
  2. Add the wrapper in packages/api-client/src/client.ts with the same name.
  3. Run pnpm openapi + cd services/api && make openapi to 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 operationsPOST /v1/things creates one thing; POST /v1/things/batch is a per-feature decision when (and only if) the feature needs it.
  • Partial updates — convention is PATCH with 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. Run make openapi (Go types) and pnpm openapi (TypeScript types) after editing the spec; the drift test at internal/core/server/openapi/spec_test.go fails if routes.go, the spec, and the test's expectedRoutes table 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)