Create a product
/api/productsAdds 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.
Authorization
sessionCookie The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across
numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.
Every request re-validates it against the Auth server (getUser()), never by decoding the cookie
locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified
app_metadata.company_id claim and enforced by row-level security; it is never read from request
input, on any route, ever.
role (admin / staff) is deliberately not in RLS. It gates specific actions in route code,
the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's
own check is the enforcement.
In: cookie
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/api/products" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "price": 0 }'{ "product": { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08" }}List staff (API key) GET
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.
Update a product, or archive/restore it PATCH
Two shapes in one route. A body of exactly `{"archived": true|false}` sets/clears `archived_at` and returns immediately, no other field required. Any other body is a full replacement of the catalog fields (parseProductInput's contract); `stockOnHand` is never accepted here, only through POST /api/products/{id}/stock. RLS scopes the update, so an id from another org matches zero rows and returns 404. **Appointments only**, see POST /api/products.