Shared

Cancel a transfer in progress (owner only)

post/api/organization/transfer/cancel

The escape hatch the cooling-off window exists to provide: the real owner reads the notification, sees a transfer they did not start, and kills it. Works in both live states.

Owner only, not any admin. An admin who could unilaterally veto could make transfer permanently un-completable in any organization with one disgruntled admin; admins are notified instead and can escalate. Compare-and-set on status, so this cannot race the cron into cancelling a transfer that already completed.

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

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/api/organization/transfer/cancel"
{  "ok": true}

Confirm a pending ownership transfer (owner, via emailed link) POST

**POST, never GET, and that is a security decision rather than REST manners.** This URL arrives in an inbox, and inboxes are full of things that fetch URLs without a human: Outlook Safe Links, mail scanners, Slack unfurls, antivirus proxies. A mutating GET would let a scanner start the cooling-off clock AND burn the single-use token, so the real owner's click would report "already used" and they could not tell a scanner from an attacker. The emailed link lands on a page, which posts here on a real click. **Two factors are required: the token AND the owner's own session.** A link that leaked; forwarded, logged by a mail gateway, in a screenshot; is inert on its own. Single use is structural: confirming nulls the whole token triple, so there is no `used` flag anyone can forget to check. Comparison is constant-time against a real digest even on a miss, and every failure returns one identical message so a leaked link cannot be used to map out why it failed.

Create a business: a new organization, or another one in the caller's own (onboarding) POST

Where a business is born, now that a tenant is two rows deep (migration 0031). **The only creation path**: `POST /api/companies` was a second one and was deleted on 2026-07-29, having never had a caller and having created companies with no organization above them. **Two modes, decided by the caller's `company_id` claim, over one identical write set.** *No claim*: a brand new tenant. Creates `book_organizations` -> `book_companies` (with the structured address) -> `book_org_members` (admin) -> `book_organization_members` (owner) -> the `company_id` claim. `organizationName` is required. *A claim, and the caller OWNS the organization their active business belongs to*: adds a business to that organization. Same writes minus the two that belong to an organization's own creation: no `book_organizations` row, and no second `book_organization_members` row (it exists, and `unique (organization_id, user_id)` would reject it). `organizationName` is ignored: the organization is resolved from the caller's verified ownership, never from the body. *A claim, and the caller does NOT own it*: 409, exactly as before. A plain admin or staff member cannot create a business. The target organization is the one the caller's ACTIVE business belongs to, not "any organization this user owns". Those coincide today; the active-business rule is the one that stays unambiguous if they ever stop coinciding, and it matches what the operator is looking at. `requireMember()` is deliberately not used, for the reason `POST /api/organization/switch-business` sets out: it answers whether the caller is a member of the business they are currently IN, which is not the permission being asked for. The `book_organization_members` owner row is the authorization, read with service-role and pinned to the caller's own verified `user.id`. Service-role throughout, because every write either happens BEFORE the caller's JWT carries a claim (new tenant) or against a DIFFERENT company than the one it names (added business). RLS would reject both. Each step rolls back everything before it, but only the organization it created, never one that was already there. **The claim moves to the new business in both modes**, which in the second is a switch: the operator has just created a venue with no services, staff or tables, and every one of those screens is scoped by the claim. **Hours and periods are best-effort and are NOT rolled back.** A business with no hours is an ordinary supported state that both screens render; discarding a successfully created one over a schedule insert would trade a small self-correcting gap for the loss of all the operator's work. The atomic replace RPCs are structurally unusable here; both read `company_id` off the caller's JWT, which at that instant names no company or the previous one, so they would write the schedule onto the wrong venue.