List staff (API key)
The appointments engine's staff members. Engine-guarded, ordered by id: see GET /api/v1/services for why.
serviceIds is the one place a v1 list does more than select a row, and it earns the exception twice over: which services a staff member can perform is the fact a program needs before it can offer anyone a slot, and the field is not invented here. The public booking config already exposes serviceIds per provider, and POST /api/providers already accepts it. Omitting it would be the inconsistency.
It comes from a second query against the link table, filtered by the key's own company_id rather than trusting the ids from the first. The link table carries its own company_id for exactly that reason (migration 0013). Two queries rather than a PostgREST embed, so the link table's name never becomes part of this contract.
Authorization
bearerApiKey providers:readThe scheme for programmatic callers: partner integrations, a customer's own scripts, the planned
skill and MCP server. Accepted by the /api/v1/* operations and by nothing else; the rest of this
document is session-cookie only. Every :read scope below has a GET /api/v1/<resource> behind it, and
a static check fails the build if a scope is added without one. Full detail lives in docs/api-keys.md; the load-bearing
parts are:
- Transport.
Authorization: Bearer sk_live_…only. No query parameter, no custom header, no cookie fallback: a credential in a URL ends up in access logs, referrers and browser history. - A key is a program with scopes, not a human with a role. It carries no
admin/staffrole at all; scopes are the entire authorization model, and an admin issuing a key is deliberately delegating their own authority. - Scopes are
resource:action, where resource is exactly the/api/<resource>folder name and action isreadorwrite.writedoes NOT implyread: they are compared by exact string. - The engine boundary is enforced twice. Engine scopes are filtered at issuance against the org's
own
business_type(an appointments org cannot be grantedreservations:*), and again at use time by the gate's vertical argument.wrong_verticalis its own 403 code. - There is no scope for key management. A key can never mint, list or revoke another key, so a
leaked key cannot be used to establish persistence.
organizationis read-only for the same reason migration 0022 exists. - Errors carry a machine-readable
codealongsideerror:missing_authorization,invalid_key,key_revoked,key_expired,organization_inactive(all 401);insufficient_scope,wrong_vertical,plan_required(403);rate_limited(429). Match oncode, never on the sentence. Note this is a RICHER error body than the{error}every session-authenticated route in this document returns. - Rate limiting is per key, a fixed 60-second window defaulting to 120 requests/minute, with
Retry-AfterandX-RateLimit-*headers on a 429. Session-authenticated routes have no rate limiting in application code, deliberately: the rest of the app is covered at the edge by a Vercel WAF rate-limit rule, which is not billed for what it blocks and never reaches a function. Seedocs/api-keys.md. - One envelope, one paging scheme. Every list answers
{ data: [...], nextCursor }; the singleton answers{ data: {...} }.nextCursoris keyset (seek) paging, opaque, and null exactly when there are no more rows. Every field is camelCase, unlike the database columns underneath. /api/v1/is a real version segment, not decoration. There is nov2yet. When one ships,v1keeps answering unchanged for a notice window of at least 6 months, and everyv1response carriesDeprecationandSunsetheaders (RFC 9745, RFC 8594) for that whole window. Only a security fix can shorten it. Seedocs/api-keys.md.
In: header
Scope: providers:read
Query Parameters
Clamped, never rejected: values below 1 or unparseable fall back to 50, values above 200 become 200. A ?limit=1e9 from a program is far more likely to be a client bug than an attack, and answering 200 rows beats answering an error it retries in a loop.
1 <= value <= 20050The nextCursor from the previous response, passed back unchanged. Omit it for the first page. Opaque: it is base64url of an internal position and the encoding is not part of the contract; treat it as a token, do not construct one. A cursor this API did not issue is a 400 invalid_cursor rather than a silently-ignored parameter, because silently restarting at page one is how a paging loop becomes an infinite one.
The scheme is keyset (seek) paging on (order column, id), not OFFSET. OFFSET is wrong under concurrent writes: a booking created between page 1 and page 2 shifts every later row back by one, and the caller silently never sees whichever row slid across the boundary. Filters and limit may change between pages; the cursor only says where you got to.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/v1/providers"{ "data": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "email": "user@example.com", "bio": "string", "title": "string", "active": true, "avatarUrl": "string", "serviceIds": [ "a486dd3f-c8ab-4226-b78c-e435f821cb91" ] } ], "nextCursor": "string"}List services (API key) GET
The appointments engine's bookable services. Engine-guarded: a hospitality organization's key is refused with `wrong_vertical` **even if it holds `services:read` and even if the org has `book_services` rows**, the boundary is about what the organization IS, not about what happens to be in its tables. **Ordered by `id`, which is stable but arbitrary.** `book_services` has no `created_at` (none of the four configuration tables do), and keyset paging needs a column that is both unique and stable, so `id` is the only candidate. Not worth a migration: a business sells tens of services, so the first page of 200 is the whole list in practice, and a caller wanting them alphabetical can sort them itself. Documented rather than hidden. `active: false` rows ARE returned. A program reconciling against its own copy needs to see a service that was switched off, not have it silently disappear; the public booking widget is where `active` is filtered.
Create a product POST
Adds a retail catalog item. `company_id` comes from the verified JWT claim, never the body. `stockOnHand` is the opening count, written directly ONLY on create: every change after this goes through POST /api/products/{id}/stock, which records a reason. **Appointments only** as of 2026-09-06: see migration 0200's own comment for why hospitality never had a real equivalent here at all, and /api/experiences for the screen it actually gets.