switching clears the token & responses
No token
53

Accepting an invitation while signed in

A user who already holds a session finds an invitation in their own membership list and accepts it in place — no link, no code. The signed-in alternative to scenario 52, which starts from the invitation message; which one applies is decided by whether the invitee holds a session.

  1. GET /v1/me/memberships → invitations appear as status: invited rows beside the active ones. Render them as “accept”, never as “switch”
  2. POST /v1/me/memberships/{tenantId}/accept → no body, no token → 200 { tenantId, status: active }. Accepting twice is also 200
  3. Optional: POST /v1/auth/switch-tenant → enter the tenant — accepting does not move the session
GET/v1/me/membershipsauthFind the invited rows

The 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.

Bearer token
→ GET https://api.kerja.team/v1/me/memberships
↩︎Response 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[].tenantIdUUIDalways
The tenant. For an active row, feed it to POST /v1/auth/switch-tenant; for an invited row, to POST /v1/me/memberships/{tenantId}/accept.
data[].namestringalways
The tenant's organization name — a human-readable label so the tenant picker can show something friendlier than the opaque tenantId.
data[].isDefaultbooleanalways
Whether this tenant is the user's default — where login lands. Founding another organization does not repoint it.
  • true — This is the user's default tenant — pre-select it in the tenant picker.
  • false — Not the default tenant.
data[].statusenumalways
Whether the seat is taken or only offered. This list is NOT what decides tenant policy — MFA enforcement reads active memberships only, so an unaccepted invitation never binds a policy on the user.
  • active — 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.
POST/v1/me/memberships/{tenantId}/acceptauthAccept the invitation in place

Takes NO body and NO invitation token. IDENTITY IS THE CONTROL: the caller comes from the gateway context, and the membership row is itself the evidence that this tenant invited this user — {tenantId} names WHICH invitation, never WHOSE. (POST /v1/auth/invitations/accept demands a token because it proves possession of the CHANNEL, the only proof a brand-new invitee can offer; this caller already has an account and arrived through the gateway.) 200 { tenantId, status: active }; accepting twice is also 200 — a button a user can click twice must not read as a failure — and only the first acceptance writes an audit row. It issues NO SESSION (re-minting would move a signed-in user's tenant out from under them), grants NO device trust (that is earned by proving a channel, which this caller has not done), and writes nothing global — not the password, status, login mode or display name. 404 not_found when there is no invitation for that tenant — also the answer for a suspended or revoked seat, which is not an invitation.

Path parameters
ℹ︎Field guide — what each value means & where it comes from1 field

Accepts an invitation the caller can already see at GET /v1/me/memberships (a status: invited row) — the verb that makes that row actable. Auth; NO body and NO invitation token: the caller is identified by the gateway context, and the membership row is itself the evidence that this tenant invited this user. 200 { tenantId, status: active }; accepting twice is also 200 (only the first writes an audit row). It issues NO session (re-minting would move a signed-in user's tenant out from under them), grants NO device trust, and writes nothing global — no password, status, login mode or display name. Afterwards the seat is active and therefore switchable (switch-tenant). Walk-through: scenario 53.

tenantIdpathUUIDrequiredfrom a response
WHICH invitation — never WHOSE. Copy the tenantId of a status: invited row from GET /v1/me/memberships (or from the invitations[] of an invitationPending login answer). No invitation for this tenant → 404 not_found — also the answer for a suspended or revoked seat, which is not an invitation.
Bearer token
→ POST https://api.kerja.team/v1/me/memberships/{tenantId}/accept
↩︎Response guide — what comes back & what each value means2 fields

200 OK, { data } envelope. Accepts, IN PLACE, an invitation the signed-in caller can see in GET /v1/me/memberships (scenario 53) — no body, no invitation token: the gateway context names the caller and the invited membership row is itself the evidence. Issues NO session (the current one is not moved to the new tenant), grants no device trust, and writes nothing global. Accepting twice answers 200 again (only the first writes an audit row). 404 not_found when there is no invitation for that tenant — also for a suspended or revoked seat.

data.tenantIdUUIDalways
Echoes the tenant whose seat was taken. To work in it, POST /v1/auth/switch-tenant with this id.
data.statusenumalways
The membership's new state.
  • active — The only value on a 200 — the seat is active, now switchable, and counts for that tenant's MFA policy. GET /v1/me/memberships shows it as active.
POST/v1/auth/switch-tenantauthOptional — enter the tenant

Switching to an invited row is 403, which is why the accept comes first. Accepting did not move the session; this does — and, as ever, the switch is session-wide, so every open app follows it (scenario 21).

Request body (JSON)
ℹ︎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 response
The id of the tenant to switch into. The user does not type this freely; copy it from a prior tester response that lists the user's tenants — a GET /v1/me/memberships row, or the tenantId returned by POST /v1/me/organizations / POST /v1/me/app-joins. Must be a UUID (malformed → 422). The user must hold an ACTIVE membership in this tenant or the call returns 403 — an invited row must be accepted first (scenario 53).
Bearer token
→ POST https://api.kerja.team/v1/auth/switch-tenant
↩︎Response 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.accessTokenJWTalways
Newly minted short-lived bearer token scoped to the target tenant's roles. Auto-captured by the tester as the active bearer token for subsequent calls.
data.tokenTypeenumalways
Token scheme to use in the Authorization header (Authorization: Bearer <accessToken>).
  • Bearer — Send the access token as 'Authorization: Bearer <accessToken>'.
data.expiresInintegeralways
Lifetime of the access token in seconds (e.g. 600 = 10 minutes). Client should refresh or switch before it elapses.