Switching between tenants
Sessions & devicesA user who belongs to more than one tenant changes which one their session acts in.
- GET /v1/me/memberships → tenants the user belongs to (default flagged)
- POST /v1/auth/switch-tenant → new accessToken scoped to the target tenant's roles; refresh rotated
/v1/me/membershipsauthList my membershipsThe tenant picker, plus any UNACCEPTED INVITATIONS. Each row carries the tenant's name (so a picker shows a human label, not the opaque tenantId), isDefault, and a status that is load-bearing: active, or invited — a seat you were offered and have not taken. Render an invited row as “accept” (scenario 53), NEVER as “switch”: switch-tenant needs an ACTIVE membership and answers 403 for an invited one. Suspended and soft-deleted memberships appear in neither state. This is not the query that decides tenant MFA policy — that reads active memberships only.
Field guide — what each value means & where it comes fromno inputs
No request body, path params, or query params. Just a bearer-authenticated GET that lists the tenants the caller belongs to (the tenant picker) — { tenantId, name, isDefault, status } — including UNACCEPTED INVITATIONS: status is active or invited, and the difference is load-bearing. Render an invited row as “accept” (POST /v1/me/memberships/{tenantId}/accept, scenario 53), never as “switch” — switch-tenant answers 403 for it. Suspended and soft-deleted memberships appear in neither state. The tester supplies the access token and the gateway injects x-gateway-context, so you set nothing yourself. Returns 403 forbidden if the gateway context is missing or invalid.
GET https://api.kerja.team/v1/me/membershipsResponse guide — what comes back & what each value means4 fields
200 OK. Body is the standard { data } envelope where data is an ARRAY — one row per tenant the user holds an ACTIVE seat in, plus every UNACCEPTED INVITATION. Suspended and soft-deleted memberships appear in neither state. No notable response headers. The status of each row is load-bearing: a picker must render an invited row as "accept", never as "switch".
data[].tenantIdUUIDalwaysdata[].namestringalwaysdata[].isDefaultbooleanalwaystrue— This is the user's default tenant — pre-select it in the tenant picker.false— Not the default tenant.
data[].statusenumalwaysactive— A seat the user holds — switchable with POST /v1/auth/switch-tenant, and counted for tenant MFA policy.invited— A seat offered and not yet taken — switch-tenant answers 403 for it. Signed in: accept in place with POST /v1/me/memberships/{tenantId}/accept, no token needed (scenario 53). Not signed in: POST /v1/auth/invitations/accept with the link or code (scenarios 50–52). Listed so the invitation stays discoverable after the message itself is gone.
/v1/auth/switch-tenantauthSwitch active tenantNeeds a sid in the gateway context. The new accessToken (scoped to the TARGET tenant's roles, which may differ — admin in one, member in another) is auto-captured and the refresh token is rotated. 403 without an ACTIVE membership in the target — an invited seat is not one. Like refresh, this is a re-mint and never triggers a device challenge. THE SWITCH IS SESSION-WIDE, SO EVERY OPEN APP FOLLOWS IT (scenario 20): one session holds one tenant, so switching in app A repoints EVERY live chain under that session before rotating the caller's own — app B's next request carries a stale tenant_id and is refused, and B lands in the new tenant on its next renewal. Repointing all of them is not an optimisation: a chain left on the old tenant cannot see the repointed session under RLS and its app reports the session gone. THAT CONSEQUENCE MUST BE VISIBLE IN THE UI, NOT DISCOVERED AS A BUG — “switch tenant” moves every tab, so it is a deliberate, announced act; it is also why an app the caller holds only in ANOTHER tenant is absent from GET /v1/me/apps rather than silently switching the tenant for them when picked. The app the caller is IN survives the switch: the re-mint stays in the chain it just retired, so azp is carried across and the user is not bounced out of the product they were using.
Field guide — what each value means & where it comes from1 field
Switches the active tenant for the current session and re-mints tokens scoped to the target tenant; rotates the refresh_token cookie. Requires an authenticated session (the tester supplies Authorization and the gateway injects x-gateway-context with the session id); the caller must already hold an active membership in the target tenant. Body has one field.
targetTenantIdbodystringrequiredfrom a responsePOST https://api.kerja.team/v1/auth/switch-tenantResponse guide — what comes back & what each value means3 fields
200 OK with the standard { data } envelope; Cache-Control: no-store, and Set-Cookie: refresh_token (rotated) sets the new refresh cookie. The body re-mints an access token scoped to the target tenant's roles.
data.accessTokenJWTalwaysdata.tokenTypeenumalwaysBearer— Send the access token as 'Authorization: Bearer <accessToken>'.
data.expiresInintegeralways