Conventions
What is true of every route.
Tenancy is never request input
On no route, ever, is the organization read from a body field, header or query parameter. A session request takes it from the verified app_metadata.company_id claim and row-level security enforces it a second time. An API-key request takes it from the key row. Because a key has no Supabase JWT, RLS has no claim to read there, so the handler's explicit company_id filter is the entire tenant boundary. A static check asserts that filter exists on every key-authenticated route.
Errors are one shape
Every non-2xx body is { "error": "a sentence" }. Key-authenticated routes add { "code": "machine_readable" }: match on the code, never on the sentence. Session routes have no error codes at all: the HTTP status is the code.
PATCH does not always mean partial
Some PATCH routes are true partial updates that write only the keys present in the body: the two booking editors and the client editor. Others rebuild every field from the body on every call, so omitting a field resets it: PATCH /api/companies and the four full-replacement setup routes (services, providers, tables, service periods). Each operation says which it is. Read before you send.
Derived fields cannot be set
ends_at is always computed from the start plus a duration. An appointment's price and duration_minutes are snapshotted from the service chosen; a reservation's turn_minutes is a free field, because a reservation has no service to snapshot a length from. That difference is the clearest single illustration of why these are two engines.
Double-booking is a database guarantee
Overlap is prevented by Postgres exclusion constraints, not by an application check. Every write that could collide surfaces that as a 409, and the guest-facing flows re-derive the requested slot server-side rather than trusting a posted time: the constraint is the arbiter, the availability read is only an optimisation.
Case is not uniform, and that is documented
Request bodies are camelCase. Responses are camelCase except where a handler selects a database row straight back: the two guest-checkout confirmations and the /api/v1 list endpoints return snake_case. Each of those says so at the operation.
Rate limiting exists only on /api/v1
API keys get a per-key fixed 60-second window, 120 requests per minute by default. Every /api/v1 response states the policy in RateLimit-Policy (IETF httpapi draft), and every response to an identified key adds RateLimit with what is left, plus the older X-RateLimit-* trio. A 429 also carries Retry-After. Every session-authenticated route has no rate limiting whatsoever. There is also no CORS policy anywhere, so the session surface is same-origin in practice.
Versioning is explicit, and deprecation has a floor
/api/v1/ is a real version segment, not decoration. Adding a field, an endpoint or a scope is never a breaking change; one is reserved for an actual break to a published shape. When a v2 ships, v1 keeps answering unchanged for a notice window of at least 6 months, and every v1 response carries Deprecation and Sunset headers (RFC 9745, RFC 8594) for that whole window, not only a changelog entry. The one exception is a security fix: if keeping v1 running would expose data, the window can be shorter, and the notice says why. There is no v2 yet, so neither header is on the wire today.
A malformed UUID is a 404
Postgres raises 22P02 for an unparseable uuid, and routes map that to "not found" rather than letting it surface as a 500. A path id belonging to another organization is also a 404, since row-level security simply matches zero rows. An id in the body that does not resolve is usually a 400 instead: it is a validation failure, not a missing resource.