switching clears the token & responses
No token
24

Founding your own organization

Organizations

A signed-in user — including a plain member of somebody else's organization — creates an organization of their own and becomes its admin. Signed-out counterpart: scenario 15. The app-driven variant, founding an organization to receive a free app, is scenario 57.

  1. GET /v1/me → read foundedTenantId. null → offer “Create your organization”; set → offer a way INTO that organization instead, because founding would only 409
  2. POST /v1/me/organizations { name } (+ optional Idempotency-Key) → 200 { tenantId, name, status: active }; you are its founding admin. No new session is issued
  3. GET /v1/me/memberships → the new organization is listed as an active row
  4. POST /v1/auth/switch-tenant { targetTenantId } → enter it. Until this call the session stays in the tenant it was in (scenario 21)
GET/v1/meauthHave I already founded one? (foundedTenantId)

foundedTenantId is the LIVE organization this identity founded, or null — the read side of the cap POST /v1/me/organizations enforces, derived from the SAME predicate the refusal is (the tenant whose created_by is you, still live and active). It consults neither memberships nor roles: being made an admin of someone else's organization never populates it, and a founder who no longer holds a seat in the organization they founded is STILL reported, because they are still capped. Decide whether to OFFER founding from this field, so an owner is never shown a form that can only ever 409.

ℹ︎Field guide — what each value means & where it comes fromno inputs

No request body, path params, query params, or caller-set headers. It only needs the authenticated session: the tester sends the captured Authorization access token and the gateway injects x-gateway-context. Returns the current user's profile — id, email, phone, displayName, hasMfaEnabled, emailVerifiedAt, phoneVerifiedAt, loginMode — plus the fields the §153 / §136 flows read first: hasPassword (false → every profile change is refused 409 no_password at POST /v1/me/step-up; set one through the reset flow, scenario 34), pendingEmail / pendingPhone and pendingChanges[] (the live credential changes in CredentialChange shape — email and phone keep naming the values that still sign in, scenario 36), and foundedTenantId (null → founding is offered; set → POST /v1/me/organizations would only 409, scenario 24). 404 if the user record is gone.

Bearer token
→ GET https://api.kerja.team/v1/me
↩︎Response guide — what comes back & what each value means24 fields

200 OK. Body is the standard { data } envelope wrapping the current user's profile; no Set-Cookie. Internal columns such as the password hash are never returned. Since §153 the profile also carries the IN-FLIGHT credential changes (pendingEmail, pendingPhone, pendingChanges[]) — email / phone keep naming the values that still sign in — plus hasPassword (can a step-up start at all?) and foundedTenantId (should the client offer founding an organization?). PUT /v1/me/display-name and PUT /v1/me/login-mode answer with this same shape.

data.idUUIDalways
The authenticated user's unique identifier. Use to reference this identity in subsequent calls.
data.emailstring | nullalways
The email that SIGNS IN now (NFC-normalized), or null on a phone-only account. An in-flight email change never alters it — the new address sits in pendingEmail until it applies. May be unverified; check emailVerifiedAt.
data.pendingEmailstring | nullalways
The new address of a live email change (awaiting its code, or held until effectiveAt), or null. It never signs in while pending (scenario 30).
data.pendingPhonestring | nullalways
The new number of a live phone change, or null (scenario 31). Never signs in while pending.
data.pendingChangesCredentialChange[]always
Every LIVE change row (awaiting_verification or held), so a client can offer resend and cancel without a second call; [] when nothing is in flight. Full history is GET /v1/me/credential-changes (scenario 36).
data.pendingChanges[].idUUIDalways
The change row's id. Not needed to act on it — verify / resend / cancel address the caller's ONE live change of that kind (/v1/me/email-change, /v1/me/phone-change, /v1/me/password-change), never an id.
data.pendingChanges[].kindenumalways
Which credential the change replaces. At most one live change per kind; email beside phone only when both are strong, and a password change never sits beside a contact change.
  • email — An email change (scenario 30) — the code went to the NEW address; confirm at POST /v1/me/email-change/verify.
  • phone — A phone add or replacement (scenario 31) — the WhatsApp/SMS code went to the NEW number; confirm at POST /v1/me/phone-change/verify.
  • password — A password change (scenario 35) — the code went to the CURRENT value of the loginMode channel; confirm at POST /v1/me/password-change/verify. value is always null.
data.pendingChanges[].valuestring | nullalways
The target address / number this change would install. null for the password kind (never echoed) and for a closed contact change. Until the row is applied it does NOT sign in — the current value on the profile still does.
data.pendingChanges[].statusenumalways
Where the change stands. Only the two LIVE statuses appear in pendingChanges[] — closed rows are in GET /v1/me/credential-changes.
  • awaiting_verification — The code is out and not yet confirmed. Next: the matching …/verify with the code, …/resend for a fresh one (the old code dies), or DELETE to cancel. The current value keeps signing in. Lapses to expired after CONTACT_CHANGE_PENDING_TTL_SECONDS (24 h).
  • held — Contact changes only: the code was confirmed on PASSWORD assurance, so the change waits CONTACT_CHANGE_HOLD_SECONDS (3 days) and worker job J7 applies it at effectiveAt. Every verified current channel was sent contact_change_held with a cancel link (POST /v1/auth/credential-change/cancel); DELETE also still cancels it. The current value keeps signing in until then.
  • applied — In effect: the new email / phone is the verified credential (user.upserted projected across the user's tenants), or the new password is set and every session has ended. Terminal.
  • cancelled — Cancelled by the user (DELETE on the change route, DELETE /v1/me/credential-changes) or through the notice link. The current value was never touched. Terminal.
  • expired — Never confirmed within the pending TTL; closed by J7 with no notice. Terminal — start a new change (and a new step-up) to try again.
  • failed — At verify the target address / number had meanwhile been taken by another account; contact_change_failed was sent. Terminal.
data.pendingChanges[].assuranceenumalways
The assurance of the step-up token the request spent — it decides what verify does.
  • password — Only the current password was proven: a contact change is HELD after its code (3-day window, cancel link sent to every verified channel at request and at hold). A password change needs no more than this.
  • strong — A second factor was proven at POST /v1/me/step-up/{challengeId}/verify: a contact change APPLIES the moment its code is confirmed, and nothing is sent at request.
data.pendingChanges[].requestedAtISO-8601always
When the change was requested.
data.pendingChanges[].verifiedAtISO-8601 | nullalways
When the code was confirmed; null while awaiting_verification and on a change that never got that far.
data.pendingChanges[].effectiveAtISO-8601 | nullalways
When a HELD change applies (verifiedAt + CONTACT_CHANGE_HOLD_SECONDS) — show it as "takes effect on …". null when there is no hold.
data.pendingChanges[].expiresAtISO-8601always
When an unconfirmed change lapses to expired (requestedAt + CONTACT_CHANGE_PENDING_TTL_SECONDS, 24 h).
data.pendingChanges[].codeExpiresAtISO-8601 | nullalways
When the CURRENT code dies (email 15 min, phone 5 min). Past it, …/resend — the change itself is still live until expiresAt.
data.pendingChanges[].codeSentTostring | nullalways
Where the code went, masked (b•••@new.id, +62 812••••21) — the new value for a contact change, the loginMode channel for a password change.
data.displayNamestringalways
The user's human-readable display name. Changed only through PUT /v1/me/display-name (scenario 28).
data.hasMfaEnabledbooleanalways
Whether the user has multi-factor authentication enabled. Drives whether to surface MFA management options.
  • true — MFA is enabled — disabling it or regenerating recovery codes needs a STRONG step-up (scenario 27).
  • false — MFA is not enabled; POST /v1/me/mfa/recovery-codes would answer 409.
data.hasPasswordbooleanalways
Whether a password is set — the boolean only, never the hash. Every profile change starts by proving the current password at POST /v1/me/step-up.
  • true — Profile changes are available: start with POST /v1/me/step-up.
  • false — A passwordless (phone-only) account: step-up answers 409 no_password, so send the user through the reset flow (scenario 34) to set one first.
data.emailVerifiedAtISO-8601 | nullalways
When the current email was verified, or null. A pending email change does not reset it; it is re-stamped only when the change applies.
data.phonestring | nullalways
The E.164 phone that signs in now, or null on an email-only account. A pending phone change never alters it. Check phoneVerifiedAt before treating it as a usable channel.
data.phoneVerifiedAtISO-8601 | nullalways
When the phone was verified, or null. A verified phone is the prerequisite for phone-OTP MFA (scenario 25) and for loginMode: phone (scenario 33).
data.loginModeenumalways
The account's PRIMARY channel — set at registration (phone sign-up / phone invitation → phone; email → email) and changed only by PUT /v1/me/login-mode on a strong step-up (scenario 33). It is also where a password-change code goes.
  • email — Signs in at scenario 9 (email + password); a new-device challenge arrives as an email code.
  • phone — Signs in at scenario 10 (phone + password); a new-device challenge arrives as a phone OTP — which is why DELETE /v1/me/phone answers 409 last_credential until this is switched back to email.
data.foundedTenantIdUUID | nullalways
The LIVE, active organization this identity founded (tenants.created_by = you), or null — the same predicate POST /v1/me/organizations enforces. null → offer "found your organization" (scenario 24); non-null → that route can only 409 organization_already_owned, so offer a way into this tenant instead. Founding is not membership: an admin seat elsewhere never sets it, and a founder who left their own organization still sees it.
POST/v1/me/organizationsauthFound an organization

NOT ADMIN-GATED — the session's tenant says nothing about the right to found one, and gating on it would refuse a plain member the very act this route exists for. No step-up. The body is .strict(): a userId or any other key is 422, never silently ignored — identity comes from the verified principal. THE CAP COUNTS ORGANIZATIONS YOU FOUNDED THAT ARE STILL LIVE (created_by = you, not deleted, status active) — not memberships, not roles; an archived or deleted founded organization does not count → 409 organization_already_owned (its own code, because the UI must then offer a way INTO the one you have). YOUR DEFAULT MEMBERSHIP IS NOT REPOINTED, unless you had no active membership at all — entering the new one is an ordinary switch-tenant. Issues no session. Idempotency-Key is optional and honoured durably; founding has no business key, so a retry WITHOUT one is refused by the cap rather than replayed (a key that is not a UUID → 409 conflict). Rate-limited per user, sharing POST /v1/me/app-joins' limiter → 429. 200 rather than 201 because there is no GET /v1/me/organizations/{id} to advertise.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Founds your own organization and makes you its admin (§3.100). Any signed-in identity may do this — including a plain member of somebody else's organization: NOT admin-gated, and NO step-up (matching the createOrganization branch of POST /v1/me/app-joins, which performs the same write). 200 { tenantId, name, status: active } — 200 rather than 201 because there is no read route to advertise. Issues no session; your default membership is NOT repointed (unless you had no active membership at all), so entering the new one is an ordinary switch-tenant. CAPPED at one LIVE founded organization per identity (created_by = you, not deleted, status active — not memberships, not roles) → 409 organization_already_owned; read foundedTenantId at GET /v1/me first so the screen never offers a form that can only 409. Rate-limited per user, sharing app-joins' limiter → 429. Walk-through: scenario 24.

Idempotency-KeyheaderUUIDoptionalclient-generated
OPTIONAL and durable — the card offers the input empty, and an empty input sends no header. Founding has no business key, so a retry WITHOUT a key is refused by the ownership cap (409 organization_already_owned) rather than replayed; with the same key and body it replays the original 200. A key that is not a UUID → 409 conflict.
namebodystringrequiredyou choose
The organization's display name, 1–200 chars. The body is .strict() and carries a name and NOTHING else — a userId or any other key is 422, never silently ignored, because identity comes from the verified principal. Missing or over 200 chars → 422.
Idempotency-Key header — optional; empty sends no header
Bearer token
→ POST https://api.kerja.team/v1/me/organizations
↩︎Response guide — what comes back & what each value means3 fields

200 OK (not 201 — there is no GET /v1/me/organizations/{id} to name in a Location), { data } envelope. The signed-in caller founded their own organization and is its admin (scenario 24). Issues NO session and does not repoint the default membership (unless the caller had no active membership at all) — entering the new tenant is an ordinary POST /v1/auth/switch-tenant. Capped at ONE live founded organization per identity (the predicate GET /v1/me reports as foundedTenantId): a second is 409 organization_already_owned. Idempotency-Key optional and honoured durably; 429 on the per-user organization-creation limiter shared with POST /v1/me/app-joins.

data.tenantIdUUIDalways
The new organization — now also GET /v1/me's foundedTenantId. Pass it to POST /v1/auth/switch-tenant to act in it.
data.namestringalways
The organization name as stored (echo of the request).
data.statusenumalways
The tenant's lifecycle state.
  • active — The organization is live and usable immediately; it counts against the one-founded-organization cap.
GET/v1/me/membershipsauthThe new organization is an active row

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/auth/switch-tenantauthEnter the new organization

Paste the tenantId the founding call returned. Until you switch, the session stays in the tenant it was in — founding does not move you. 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.