Founding your own organization
OrganizationsA 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.
- GET /v1/me → read foundedTenantId. null → offer “Create your organization”; set → offer a way INTO that organization instead, because founding would only 409
- POST /v1/me/organizations { name } (+ optional Idempotency-Key) → 200 { tenantId, name, status: active }; you are its founding admin. No new session is issued
- GET /v1/me/memberships → the new organization is listed as an active row
- POST /v1/auth/switch-tenant { targetTenantId } → enter it. Until this call the session stays in the tenant it was in (scenario 21)
/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.
GET https://api.kerja.team/v1/meResponse 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.idUUIDalwaysdata.emailstring | nullalwaysdata.pendingEmailstring | nullalwaysdata.pendingPhonestring | nullalwaysdata.pendingChangesCredentialChange[]alwaysdata.pendingChanges[].idUUIDalwaysdata.pendingChanges[].kindenumalwaysemail— 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 | nullalwaysdata.pendingChanges[].statusenumalwaysawaiting_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[].assuranceenumalwayspassword— 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-8601alwaysdata.pendingChanges[].verifiedAtISO-8601 | nullalwaysdata.pendingChanges[].effectiveAtISO-8601 | nullalwaysdata.pendingChanges[].expiresAtISO-8601alwaysdata.pendingChanges[].codeExpiresAtISO-8601 | nullalwaysdata.pendingChanges[].codeSentTostring | nullalwaysdata.displayNamestringalwaysdata.hasMfaEnabledbooleanalwaystrue— 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.hasPasswordbooleanalwaystrue— 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 | nullalwaysdata.phonestring | nullalwaysdata.phoneVerifiedAtISO-8601 | nullalwaysdata.loginModeenumalwaysemail— 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/v1/me/organizationsauthFound an organizationNOT 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.
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-generatednamebodystringrequiredyou choosePOST https://api.kerja.team/v1/me/organizationsResponse 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.tenantIdUUIDalwaysdata.namestringalwaysdata.statusenumalwaysactive— The organization is live and usable immediately; it counts against the one-founded-organization cap.
/v1/me/membershipsauthThe new organization is an active rowThe 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-tenantauthEnter the new organizationPaste 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).
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