Shared

Create a client

post/api/clients

A client with no booking behind it yet. Shares its find-or-create-by-email path with the two staff-side create routes, and reports which branch it took so the UI can say "you have merged into an existing record" instead of silently touching someone else's.

With no email there is no dedup key at all, so a fresh row is always created. That is an accepted tradeoff, not a gap: book_customers allows a null email and the unique index is total.

Authorization

sessionCookie
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

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/clients" \  -H "Content-Type: application/json" \  -d '{    "name": "string",    "phone": "string"  }'
{  "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",  "merged": true,  "existingName": "string"}

Attach a screenshot to a feedback submission you just sent POST

A second request, not folded into the JSON POST above: the same "create the entity, then a dedicated route for its image" shape every other upload in this app takes. Every uploaded byte is decoded and RE-ENCODED through sharp before it ever reaches storage, never stored verbatim: the whole point, since a screenshot is the one attachment surface in this app that could carry a hidden payload for something other than a browser to read later ("AI reading this on the other end", 2026-09-01). See the route's own header for the full four-part threat model (format sniffed against a hardcoded magic-number allowlist before sharp ever sees the bytes; EXIF/XMP/ICC and anything appended past the image's own end-of-file marker dropped by the re-encode; input pixel count capped before decode to refuse a decompression bomb; and an ownership + 10-minute recency check, since the caller's own JWT cannot even SELECT this table to guess at one). One attachment per submission. A second attempt is a 409, not a silent replace.

Update a client PATCH

Partial: only present keys are written. **Email is deliberately not editable.** It is the client identity (the unique index on `company_id, email`), so changing it is a merge or a split of two records and their histories, not a field edit. Sending `email` is silently ignored. `tags` and `notes` are both the venue's PRIVATE data about the client. Neither is ever rendered on a guest-facing surface or included in any email or SMS; the notify payload builders name their columns explicitly so they cannot start doing so by accident. **`marketingStatus` (migration 0143) is one-directional.** The only accepted value is `'unsubscribed'`; any other value, including `'subscribed'`, is a 400. Consent is the client's to give, never staff's to restore on their behalf, so this route has no way to resubscribe someone.