switching clears the token & responses
No token
37

Migrating a client off retired routes

Legacy clients

§153 split profile editing into one route per field, each spending a step-up token. The old routes stay mounted and answer 410 gone — with a message naming the successor — so an old client fails loudly instead of half-working. A few older sign-in routes are DEPRECATED BUT STILL WORK. This is the map for a client that still calls either kind; every 410 card below should answer 410 (or 403 without a gateway context), never 200.

  1. PATCH /v1/me → 410. Name → PUT /v1/me/display-name (scenario 28); email → POST /v1/me/email-change + verify (scenario 30); login mode → PUT /v1/me/login-mode (scenario 33)
  2. POST /v1/auth/password/change → 410. → step-up + POST /v1/me/password-change + verify (scenario 35)
  3. POST /v1/me/phone/otp/request · POST /v1/me/phone → 410. → POST /v1/me/phone-change + verify (scenario 31)
  4. POST /v1/me/mfa/otp/request → 410. A step-up code is sent by POST /v1/me/step-up/{challengeId}/code, bound to the challenge that will spend it (scenario 27)
  5. POST /v1/auth/phone/register → deprecated, still 200 — the one-shot password-less phone sign-up; new clients use registration/phone/verify + complete (scenario 3)
  6. POST /v1/auth/mfa/otp/request · POST /v1/auth/mfa/verify → deprecated, still working — the single-hop MFA challenge; new clients follow the chain at login/challenge/verify + resend (scenario 12)
PATCH/v1/meauthRetired — PATCH /v1/me (expect 410)

410 gone, and the message names the successors: displayName → PUT /v1/me/display-name, email → POST /v1/me/email-change + /verify, loginMode → PUT /v1/me/login-mode. DO NOT RETRY A 410 — it has no side effect and will never succeed. Without a gateway context the same route answers 403 first. The successors differ in SHAPE, not only in path: the old body carried the current password inline; every successor first needs a step-up token, and most send a code you must confirm.

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

RETIRED (§153 X-2) — answers 410 gone for every caller (403 forbidden first without a gateway context); the message names the successors. The body below is documented only so an old client can map it: the route answers 410 WITHOUT reading it, so nothing is applied, staged or validated. One field, one endpoint, one step-up token now: displayName → PUT /v1/me/display-name (scenario 28); email → POST /v1/me/email-change + POST /v1/me/email-change/verify (scenario 30); loginMode → PUT /v1/me/login-mode (scenario 33). The successors differ in SHAPE, not only path — each first needs a step-up token from POST /v1/me/step-up. Do not retry a 410 (scenario 37).

displayNamebodystringoptionalyou choose
NOT READ — 410. Successor: PUT /v1/me/display-name { displayName, stepUpToken } on a display_name step-up token (password assurance is enough).
emailbodystringoptionalyou choose
NOT READ — 410. There is no more pending_email staging on this route and nothing is confirmed at POST /v1/auth/email/verify. Successor: POST /v1/me/email-change { email, stepUpToken } sends a code to the NEW address, confirmed at POST /v1/me/email-change/verify { code } (applied at once on a strong token, held for 3 days on a password one).
loginModebodyenumoptionalyou choose
NOT READ — 410. Successor: PUT /v1/me/login-mode { loginMode, stepUpToken } on a STRONG login_mode step-up token.
  • email — Ignored here (410). At PUT /v1/me/login-mode: sign in with email + password; a new-device challenge arrives as an email code.
  • phone — Ignored here (410). At PUT /v1/me/login-mode: sign in with the E.164 number + password; a new-device challenge arrives as a phone OTP.
currentPasswordbodystringoptionalyou choose
NOT READ — 410. The current password never travels in a change body any more; it is proven once at POST /v1/me/step-up.
otpCodebodystringoptionalout-of-band
NOT READ — 410. The otpCode step-up factor (and POST /v1/me/mfa/otp/request that minted it) is retired; a second factor is proven at POST /v1/me/step-up/{challengeId}/verify.
Bearer token
→ PATCH https://api.kerja.team/v1/me
↩︎Response guide — what comes back & what each value meansno body

RETIRED (§153). Every call answers 410 gone with a message naming the successor — one field, one endpoint, one step-up token: displayName → PUT /v1/me/display-name (scenario 28); email → POST /v1/me/email-change + /verify (scenario 30); loginMode → PUT /v1/me/login-mode (scenario 33). Scenario 37 exercises the 410. 403 forbidden instead when the request carries no gateway context. There is no success shape any more.

POST/v1/auth/password/changeauthRetired — POST /v1/auth/password/change (expect 410)

410 gone for every caller. A password is now changed in steps that never carry the current password in a body: POST /v1/me/step-up proves it and mints a password_change token, POST /v1/me/password-change submits the new password and sends a code to the sign-in channel, POST /v1/me/password-change/verify confirms it and ends every session (scenario 35).

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

RETIRED (§153 X-5) — answers 410 gone for every caller (403 forbidden first without a gateway context); the message names the successor. The body below is documented only so an old client can map it: the route answers 410 WITHOUT reading it, so no password is checked or changed and no session is revoked. A password is now changed in steps that never carry the current password in a change body: POST /v1/me/step-up { action: password_change, currentPassword } proves it and mints a token → POST /v1/me/password-change { newPassword, stepUpToken } holds the new password and sends a code to the loginMode channel → POST /v1/me/password-change/verify { code } applies it and ends every session (scenario 35). Do not retry a 410 (scenario 37).

currentPasswordbodystringoptionalyou choose
NOT READ — 410. Now proven at POST /v1/me/step-up (action password_change).
newPasswordbodystringoptionalyou choose
NOT READ — 410. Now sent to POST /v1/me/password-change with the step-up token (12–1024 chars).
Bearer token
→ POST https://api.kerja.team/v1/auth/password/change
↩︎Response guide — what comes back & what each value meansno body

RETIRED (§153). Every call answers 410 gone with a message naming the successor — a password is now changed in steps that never carry the current password in a body: POST /v1/me/step-up (action password_change) proves it, POST /v1/me/password-change submits the new one and sends a code to the loginMode channel, POST /v1/me/password-change/verify confirms it and ends every session (scenario 35). 403 forbidden instead when the request carries no gateway context. There is no success shape any more.

POST/v1/me/phone/otp/requestauthRetired — POST /v1/me/phone/otp/request (expect 410)

410 gone. A phone is added or replaced through POST /v1/me/phone-change, which sends the code to the new number itself (scenario 31).

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

RETIRED (§153 X-3) — answers 410 gone for every caller (403 forbidden first without a gateway context); the message names the successor. A phone is now added or replaced through POST /v1/me/phone-change { phone, stepUpToken }, which sends the code to the new number itself — there is no separate OTP-request step any more (scenario 31). Do not retry a 410 (scenario 37).

phonebodyE.164optionalyou choose
The number the old link challenge was sent to. NOT READ — the call answers 410 without looking at it.
Bearer token
→ POST https://api.kerja.team/v1/me/phone/otp/request
↩︎Response guide — what comes back & what each value meansno body

RETIRED (§153). Every call answers 410 gone with a message naming the successor — POST /v1/me/phone-change, which sends the code to the new number itself (scenario 31). 403 forbidden instead when the request carries no gateway context. There is no success shape any more.

POST/v1/me/phoneauthRetired — POST /v1/me/phone (expect 410)

410 gone. The code sent by POST /v1/me/phone-change is confirmed at POST /v1/me/phone-change/verify. Adding a phone is no longer OTP-only: it spends a step-up token like every other credential change.

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

RETIRED (§153 X-3) — answers 410 gone for every caller (403 forbidden first when there is no gateway context); the message names the successor. The old OTP-only link — { phone, code } with no step-up — no longer exists: adding or replacing a phone now spends a step-up token like every other credential change. Successor: POST /v1/me/phone-change { phone, stepUpToken } (which sends the code to the new number itself) + POST /v1/me/phone-change/verify { code } — scenario 31. Do not retry a 410; it has no side effect and never succeeds (scenario 37).

phonebodyE.164optionalyou choose
The number the old body linked. NOT READ — the route answers 410 before looking at the body. Send it to POST /v1/me/phone-change instead.
codebodystringoptionalout-of-band
The link-purpose OTP the old body carried. NOT READ — 410. The successor code arrives at the NEW number from POST /v1/me/phone-change and is confirmed at POST /v1/me/phone-change/verify.
Bearer token
→ POST https://api.kerja.team/v1/me/phone
↩︎Response guide — what comes back & what each value meansno body

RETIRED (§153). Every call answers 410 gone with a message naming the successor — POST /v1/me/phone-change/verify, which confirms the code POST /v1/me/phone-change sent. Adding a phone is no longer OTP-only: it spends a phone_change step-up token like every other credential change (scenario 31). 403 forbidden instead when the request carries no gateway context. There is no success shape any more.

POST/v1/me/mfa/otp/requestauthRetired — POST /v1/me/mfa/otp/request (expect 410)

410 gone. A step-up code is now sent by POST /v1/me/step-up/{challengeId}/code, bound to the challenge that will spend it — an unbound OTP could be replayed into a different action (scenario 27).

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

RETIRED (§153 X-8) — answers 410 gone for every caller (403 forbidden first without a gateway context). It took no body; it used to text a step_up-purpose OTP to the caller's verified phone for the old otpCode factor. That factor no longer exists: a step-up code is sent by POST /v1/me/step-up/{challengeId}/code, bound to the challenge that will spend it (scenario 27). Do not retry a 410 (scenario 37).

Bearer token
→ POST https://api.kerja.team/v1/me/mfa/otp/request
↩︎Response guide — what comes back & what each value meansno body

RETIRED (§153). Every call answers 410 gone with a message naming the successor — POST /v1/me/step-up/{challengeId}/code, which sends a step-up code bound to the challenge that will spend it (scenario 27). 403 forbidden instead when the request carries no gateway context. There is no success shape any more.

POST/v1/auth/phone/registerpublicDeprecated, still 200 — POST /v1/auth/phone/register

DEPRECATED IS NOT RETIRED — this runs on the same engine as its successor and is kept so existing clients do not break. The pre-Enhancement-4 one-shot that creates a PASSWORD-LESS account (user + tenant + founding admin) in a single call and returns tokens (amr: [sms]). New clients use POST /v1/auth/registration/phone/verify + /complete (scenario 3). Policy-gated, and refused 403 registration_combination_required under combinationMode=atomic.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from5 fields

DEPRECATED BUT FULLY WORKING (scenario 37): the pre-Enhancement-4 one-shot that creates a PASSWORD-LESS phone-only account and its own tenant in one call (200, tokens with amr [sms]). New clients use the three-step path — phone/otp/request → registration/phone/verify → registration/complete — which ends in a real password (scenario 3). Public phone sign-up — no Authorization needed. The body is .strict(): only the five listed fields are accepted. You must already have completed an OTP-request step and obtained the SMS code out-of-band.

phonebodyE.164requiredyou choose
The phone number to register, in E.164 format (e.g. +15551234567). The user types this; it must match the number the SMS OTP was sent to.
codebodystringrequiredout-of-band
The numeric one-time code (default 6 digits) delivered via SMS to the phone number. The user must read it from the text message and paste it; it must be unconsumed and unexpired.
displayNamebodystringoptionalyou choose
Optional human-readable name for the new user (trimmed, 1–200 chars, NFC-normalized). The user freely chooses it.
organizationNamebodystringoptionalyou choose
Optional name for the new tenant/organization bootstrapped for the registrant (trimmed, 1–200 chars, NFC-normalized). The user freely chooses it.
trustDevicebodybooleanoptionalyou choose
DEFAULTS TO TRUE. The OTP was typed into THIS browser, so completing the call earns it a device_token cookie on the same reasoning as POST /v1/auth/registration/complete — the next sign-in here raises no new-device challenge. Send false on a shared machine to decline the grant.
→ POST https://api.kerja.team/v1/auth/phone/register
↩︎Response guide — what comes back & what each value means9 fields

200 OK with the standard { data } envelope; issues an inline session for the new phone-only user. Response headers: Set-Cookie: refresh_token (httpOnly), Set-Cookie: device_token (httpOnly) unless trustDevice: false was sent, and Cache-Control: no-store. The refresh token is delivered only via cookie, not in the body.

data.accessTokenJWTalways
Signed access token (amr=["sms"]) for the new phone-only user. Auto-captured by the tester as the active bearer token for subsequent authenticated calls.
data.tokenTypeenumalways
Scheme to use when sending accessToken; always literally Bearer (send as Authorization: Bearer <token>).
  • Bearer — Token is sent in the Authorization header as 'Bearer <accessToken>'.
data.expiresInintegeralways
Access-token lifetime in seconds (e.g. 600 = 10 minutes). Client refreshes before this elapses using the refresh_token cookie.
data.userobjectalways
The newly created phone-only identity together with its freshly bootstrapped tenant context.
data.user.idUUIDalways
Unique identifier of the new user. Feed into later user-scoped calls.
data.user.phonestringalways
The verified phone number in E.164 format (phoneVerifiedAt is set); email and password are null for this phone-only identity.
data.user.displayNamestringconditional
The user's display name. Present only when displayName was supplied in the request body; omitted/absent otherwise.
data.user.tenantIdUUIDalways
Identifier of the registrant's own newly created tenant (bootstrapped provisioning -> active with a founding admin role). Use as the active tenant context for tenant-scoped calls.
Set-Cookie: device_tokencookieconditional
Device trust for this browser — the OTP was typed here, so it earns the same grant a completed device challenge does and the next login from it skips the new-device challenge. Absent when trustDevice: false (shared machine) or device trust is disabled.
POST/v1/auth/mfa/otp/requestpublicDeprecated, still working — POST /v1/auth/mfa/otp/request

The legacy single-hop MFA challenge (phone_otp only): submit the mfaToken from a legacy-shape login ({ mfaRequired, method, mfaToken } — DEVICE_TRUST_ENABLED off) → 202 and an OTP to the masked phoneHint. Rewired onto the same engine as the chain. New clients use POST /v1/auth/login/challenge/verify + /resend (scenario 12), which can also express a chain.

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

DEPRECATED, STILL WORKING (scenario 37): the legacy single-hop MFA challenge, returned by login only when DEVICE_TRUST_ENABLED is off; new clients follow the challenge chain (POST /v1/auth/login/challenge/verify, scenario 12). JSON body with one field — the mfaToken from the login MFA challenge. No access token exists yet; the mfaToken is the only credential. This applies to phone_otp challenges only (a totp challenge needs no send). 202; re-calling resends and replaces the live challenge (throttled → 429). No verified phone on file → 409 no_verified_phone.

mfaTokenbodystringrequiredfrom a response
The single-purpose JWT returned by POST /v1/auth/login when it responds { mfaRequired: true, method: "phone_otp", phoneHint, mfaToken }. The user does not type this — copy it from the prior login response in the tester. It identifies the live phone_otp challenge so IAM can send (or resend) the SMS/WhatsApp OTP. Validation: 1–4096 chars.
→ POST https://api.kerja.team/v1/auth/mfa/otp/request
↩︎Response guide — what comes back & what each value means1 field

202 Accepted, { data } envelope, Cache-Control: no-store, when the MFA OTP was sent (or resent — re-calling replaces the live code). The masked destination was already given as phoneHint in the login response. Not uniform: a bad / expired mfaToken is 401 and an account with no verified phone on file is 409 no_verified_phone (reveal).

data.statusenumalways
Confirmation that the OTP send/resend was accepted and a notification.otp_requested outbox event (purpose=mfa) was queued for the external notifier. Collect the code and call POST /v1/auth/mfa/verify.
  • accepted — The OTP request was accepted for delivery over SMS or WhatsApp (channel chosen downstream). The only value on a 202.
POST/v1/auth/mfa/verifypublicVerify MFA challenge (deprecated single-hop)

DEPRECATED but fully working — rewired onto the same engine as the challenge chain with a one-element plan and the same acceptance order (TOTP → recovery code → phone OTP). code is a phone OTP, a TOTP code, or a single-use recovery code. Success auto-captures data.accessToken; amr becomes [pwd, mfa]. New clients should use POST /v1/auth/login/challenge/verify (scenario 12/13), which can also express a chain.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

DEPRECATED, STILL WORKING (scenario 37): completes the legacy single-hop MFA challenge; new clients follow the chain at POST /v1/auth/login/challenge/verify (scenario 12). Two body fields; no Authorization header — the mfaToken issued by login is the credential. Sent with Cache-Control no-store. On success the full session (amr [pwd, mfa]) — or, for an account that belongs to no organization, the noOrganization / invitationPending body of POST /v1/auth/login (scenario 15 / 52).

mfaTokenbodystringrequiredfrom a response
The short-lived JWT returned by POST /v1/auth/login (§3.6) in the mfaRequired branch as data.mfaToken. The user does not type it — the tester copies it from the prior login response. Validation: 1–4096 chars.
codebodystringrequiredauthenticator
The second factor the user enters: a current TOTP code from their authenticator app, a single-use recovery code, or the phone OTP sent by §3.7 (mfa purpose). Method-agnostic (IAM-MFA-05) — IAM validates it against whichever factor the account holds. Validation: 1–32 chars. A correct recovery code is consumed (single-use).
→ POST https://api.kerja.team/v1/auth/mfa/verify
↩︎Response guide — what comes back & what each value means15 fields

200 OK with a { data } envelope and Cache-Control: no-store. Normally issues the full session (same shape as a non-MFA login): Set-Cookie refresh_token (httpOnly) plus the access token and user in the body; amr is [pwd, mfa] whichever factor (phone OTP, TOTP, recovery code — the latter consumed) was used. It can also end in one of the two SESSIONLESS 200s of §3.6 when the chain clears for an account that belongs nowhere — { noOrganization, bootstrapToken, expiresIn } (scenario 15) or { invitationPending, acceptToken, expiresIn, invitations[] } (scenario 52): neither mints a session row, a refresh or device cookie, an access token or a tenant_id claim. READ the body; a 200 alone is not "signed in".

data.accessTokenstring (JWT)conditional
Session branch. The bearer token for the tenant surface; auto-captured by this tester as the active bearer. Session amr is ["pwd","mfa"].
data.tokenTypeenumconditional
Session branch. Token scheme for the Authorization header.
  • Bearer — Send the access token as Authorization: Bearer <accessToken>.
data.expiresInnumberconditional
Session branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Sessionless branches: the lifetime of the bootstrapToken / acceptToken.
data.user.idUUIDconditional
Session branch. The authenticated user's id.
data.user.emailstringconditional
Session branch. The user's email.
data.user.displayNamestringconditional
Session branch. Display name for the UI.
data.user.tenantIdUUIDconditional
Session branch. The tenant the session is scoped to (the default membership when there are several).
Set-Cookie: refresh_tokencookieconditional
Session branch only. The rotating refresh token, HttpOnly. Never set on the sessionless branches.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.