Accept an invite
/api/invites/acceptCalled by the embedded auth step on /invite/{token} right after a session exists (fresh signup or sign-in), never before. requireMember() is deliberately NOT the gate here; it requires an existing company_id claim, which is exactly the thing this route is about to create.
The token proves someone had access to the original email; matching it against the signed-in user's own address proves the person accepting right now is that same person; without it, a forwarded or leaked token could be redeemed by whoever is signed in when they click it. An account already carrying a company_id claim from a different organization is refused: moving an existing account between organizations is not supported yet, checked here against the account actually accepting rather than only at invite-send time against an email that may not have had one.
On success, three writes happen in order: the company_id claim, then the book_org_members row with the invite's role, then the invite is marked accepted.
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
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/api/invites/accept" \ -H "Content-Type: application/json" \ -d '{ "token": "string" }'{ "ok": true}Cancel a pending invite (admin) POST
Sets `revoked_at` rather than deleting the row: a revoked invite's token must stop working (checked by both accept and resend), but the row staying around is the only record that an invite existed and was cancelled. Same lookup as resend; only a still-pending invite in the caller's own company can be revoked; RLS proves the tenancy, and an already-accepted or already-revoked row reads as not found.
Request a new invite link (public) POST
The self-service "send me a new link" button on an expired `/invite/{token}` page, no session required. Deliberately keyed on the TOKEN rather than an email address: unlike an email-lookup endpoint, which would let anyone probe arbitrary addresses for "does this person have a pending invite, and where", possessing a specific 256-bit token already proves access to the original message, so nothing new is disclosed by honouring it. That is also why this can return specific errors instead of a deliberately generic response; "invite not found" leaks nothing an attacker didn't already need to have. Issues a fresh token and expiry and re-sends the invite email to the address on file, which is echoed back so the page can say where it went.