switching clears the token & responses
No token
4

Signing up with email + phone (link mode)

Signing up

A user wants both an email/password login and a phone login. Under combinationMode=link (the default) this is a two-step journey: sign up one way, then add the other credential. The add is a PHONE CHANGE (scenario 31) — a step-up token, a code to the new number — and is independent of the sign-up policy.

  1. Sign up with email (scenario 2, steps 1–2) — verification returns the session, so the caller is authenticated
  2. POST /v1/me/step-up { action: phone_change, currentPassword } → a step-up token. With no TOTP enrolled the password alone is enough (passwordOnlyAllowed: true); otherwise prove a factor (send a code, then verify) for a strong one
  3. POST /v1/me/phone-change { phone, stepUpToken } → 202; a code goes to the NEW number
  4. POST /v1/me/phone-change/verify { code } → a strong token applies it now; a password-assurance one holds it 3 days first. Once applied, phone + phoneVerifiedAt are set — the account holds both factors
  5. Optional: PUT /v1/me/login-mode → make the phone primary (scenario 33; a STRONG login_mode step-up)
POST/v1/auth/registerpublicRegister (email account)

Step 1 of the link journey — create the email account, then verify it below. Start with EMAIL: a phone-first account cannot self-add an email/password yet, so email-then-phone is the only route to both factors.

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

Public self-service sign-up that creates a new tenant and its founding admin user; no Authorization or step-up needed. A new email → 202; a TAKEN email → 409 email_already_registered (a deliberate reveal — “sign in instead” — not a silent no-op). Business-key idempotent on the unique email, so Idempotency-Key is OPTIONAL, and honoured durably when supplied. The email challenge (24h link + 15 min code, one row) is delivered out-of-band; the account cannot log in until it is verified. Under combinationMode=atomic the body must additionally carry { phone, code }.

emailbodystringrequiredyou choose
The user freely types the email for the new founding admin account. Trimmed, must be a valid email and at most 320 chars. The email-verification token is later sent to this address out-of-band.
passwordbodystringrequiredyou choose
The user freely chooses the new account password. Must be between 12 and 1024 chars and must not equal the email local-part (case-insensitive). Argon2-hashed server-side, before the DB writes.
phonebodystringconditionalyou choose
E.164 number, supplied ONLY under combinationMode=atomic, where it is REQUIRED — and rejected 422 under link, because quietly discarding a credential the caller believed they were registering is the worse failure. Must be supplied TOGETHER with code (422 otherwise): a phone with no proof, or a proof with no phone, is never a valid combination. Stored ALREADY VERIFIED, since re-proving it later would ask for a second possession proof of the same number in the same flow.
codebodystringconditionalout-of-band
The register-purpose OTP proving `phone` inline (atomic mode only), from POST /v1/auth/phone/otp/request. It is verified FIRST, inside the transaction, BEFORE any row is created — so a caller who cannot prove the number never reaches the email-taken reveal and leaves nothing behind. Consumed on success; exhausting its attempt cap consumes the challenge.
displayNamebodystringoptionalyou choose
Optional human-readable name the user types for their profile. Trimmed, 1–200 chars, NFC-normalized.
organizationNamebodystringoptionalyou choose
Optional name the user types for the new tenant/organization. Trimmed, 1–200 chars, NFC-normalized. Defaults to "<email>'s organization" if omitted.
→ POST https://api.kerja.team/v1/auth/register
↩︎Response guide — what comes back & what each value means2 fields

202 Accepted with the standard { data } envelope, for a NEW email only: the account, its tenant and founding admin are provisioned and a single challenge — a 24 h link and a 15 min 6-digit code — goes out-of-band. Not uniform: a taken email is an explicit 409 email_already_registered ("sign in instead"), never a silent 202. With an Idempotency-Key a genuine retry replays the stored 202 instead of creating a second tenant. No tokens, no user object, no Set-Cookie — the session comes at POST /v1/auth/email/verify (scenario 2).

data.statusenumalways
Acknowledgement that the sign-up was accepted. The account cannot log in until the email is confirmed at POST /v1/auth/email/verify (link or code).
  • accepted — The only value on a 202: a new account was created and the verification message sent. Next: collect the code (or open the link) for POST /v1/auth/email/verify.
data.messagestringalways
Human-readable confirmation, e.g. "Account created — verification link sent". Display only; do not branch on it.
POST/v1/auth/email/verifypublicVerify email (link token or short code)

Submit EITHER { token } (the 24h link) OR { email, code } (the 6-digit code, 15 min) — they are two halves of ONE challenge row, so redeeming either kills the other, and exhausting the code's attempt cap (EMAIL_CODE_MAX_ATTEMPTS, default 5) consumes the row and takes the link with it. On an account that already has an active membership the 200 carries an accessToken + refresh cookie (amr: [email]) — verification now ends in a session; an invitee with no active membership still gets {}. REGISTRATION-ONLY since §153: a change of address is confirmed by POST /v1/me/email-change/verify (scenario 30), never here — while an email change is live this answers 409 conflict, as it does for an already-verified address.

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

Public endpoint (no Authorization, no step-up, no Idempotency-Key). REGISTRATION-ONLY (§153 X-7): it confirms the address an account signed up with (scenario 2 / 5) — activating the account — and never applies a new one. An email CHANGE is confirmed by the code of POST /v1/me/email-change/verify (scenario 30), and while such a change is live for the account this route answers 409 conflict. The body is .strict() with EXACTLY ONE form: the link { token } OR the short code { email, code } — two representations of ONE verification_tokens row, so redeeming either marks the row used and kills the other; both forms, neither, or half a pair → 422. When the account has an active membership the 200 carries a session plus refresh_token and device_token cookies (redeeming the link or code proved the channel in this browser — no trustDevice opt-out); otherwise (e.g. an invitee) 200 { data: {} }. 409 also when the address is already verified.

tokenbodystringconditionalout-of-band
The single-use email-verification LINK token, valid 24h. The user does NOT choose this value; they obtain it from the verification message sent at registration (scenario 2 / 5) — or mint one with the “Mint email-verify token” helper — and paste it here. Supply this OR { email, code }, never both (422). 1–512 chars. Invalid or expired → 401 invalid_credentials.
emailbodystringconditionalyou choose
The address the short code was sent to — the SIGN-UP address. Required when redeeming by short code (a code is not unique, so the email is what scopes the lookup); omit it when submitting the link token. Trimmed, valid email, ≤ 320 chars.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the same message as the link (trimmed, 1–16 chars), valid EMAIL_CODE_TTL_SECONDS (default 15 min) — deliberately far shorter than the link's 24h, because ~20 bits of entropy must not sit live for a day. Attempt-capped (EMAIL_CODE_MAX_ATTEMPTS, default 5); exhausting the cap consumes the row and takes the LINK with it, forcing a resend via POST /v1/auth/email/verify/resend. The code of an email CHANGE does not work here — it belongs to POST /v1/me/email-change/verify.
→ POST https://api.kerja.team/v1/auth/email/verify
↩︎Response guide — what comes back & what each value means6 fields

200 OK with the { data } envelope. REGISTRATION-ONLY (§153): it confirms the address a sign-up (or a phone-only account's first email) registered; a change of address is confirmed by the code of POST /v1/me/email-change/verify, never here — while an email change is live this route answers 409. The 200 means the challenge (link OR code — one row, either half) was valid and consumed: the email is marked verified, the account set active, and user.upserted enqueued for every app the user holds in any of their active tenants. TWO shapes: when the account has an active membership the body carries a session (amr [email]) with Set-Cookie refresh_token AND device_token (the link or code proved the channel in this browser — no trustDevice opt-out) and Cache-Control: no-store; with no active membership (e.g. an invitee) it is a bare {} and no cookies.

data.accessTokenstringconditional
Present when the verified account has an active membership (the founding-admin case of scenario 2 / 5). Auto-captured by this tester as the active bearer token; amr is [email].
data.tokenTypestringconditional
Always "Bearer" when a token is issued.
data.expiresInnumberconditional
Access-token TTL in seconds (default 600) when a token is issued.
data.userobjectconditional
The now-active user (id, email, displayName, tenantId) when a session is issued.
Set-Cookie: refresh_tokencookieconditional
The rotating refresh token, HttpOnly — issued alongside the accessToken. Absent on the bare {} branch.
Set-Cookie: device_tokencookieconditional
Session branch only. Device trust for the browser that redeemed the link / code — which, for an emailed link, may not be the browser that began the sign-up. The next login from this browser skips the new-device challenge.
POST/v1/auth/loginpublicLog in (identifier + password)

identifier is an email OR an E.164 phone — the shape decides the lookup ({ email } is still accepted as a deprecated alias) — and it is OPTIONAL on a recognised device, where the device_token cookie names the account and the body needs only { password }. A 200 HERE IS A BODY TO READ, NOT A STATUS TO TRUST — five answers share it: { accessToken, … } (signed in — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — scenario 11/12/13), the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12), { noOrganization: true, bootstrapToken, expiresIn } (the credential is good but you belong nowhere — scenario 15), or { invitationPending: true, acceptToken, expiresIn, invitations[] } (your only seat is an invitation you have not accepted — scenario 52). The last two mint NOTHING session-shaped: no session, no refresh or device cookie, no access token. Gated by the LOGIN policy before the credential is even checked → 403 login_method_disabled (scenario 7).

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

Public, unauthenticated login. No Authorization header; send only the JSON body below. Throttled per credential/IP; response is no-store. A 200 is a BODY TO READ, not a status to trust — five shapes share it: tokens, a challenge chain (scenario 11–13), the legacy MFA challenge, { noOrganization, bootstrapToken } when the credential is good but you belong nowhere (scenario 15 — paste the token into POST /v1/auth/organizations/bootstrap), and { invitationPending, acceptToken, invitations[] } when your only seats are unaccepted invitations (scenario 52 — paste the token into POST /v1/auth/invitations/accept). The last two mint nothing session-shaped. Which one you get is NOT checkable beforehand (there is no pre-login MFA-status endpoint, by anti-enumeration design). Before the credential is examined at all, the active LOGIN policy is checked: if the channel this attempt uses is off, the answer is 403 login_method_disabled for a right and a wrong password alike (which discloses nothing — GET /v1/auth/login-policy publishes the same fact unauthenticated). Read that policy first in scenario 7.

identifierbodystringconditionalyou choose
The credential being authenticated: an email OR an E.164 phone number. The SHAPE decides the lookup, not a mode flag — anything matching ^\+[1-9]\d{6,14}$ is resolved by phone, anything else as an email — so a client never has to know which kind of account it is addressing. The credential you type must itself be VERIFIED (an email login needs a verified email, a phone login a verified phone). OPTIONAL ON A RECOGNISED DEVICE: when the body carries no identifier and a valid device_token cookie is presented, the account is resolved from the trusted device and its primary channel is used. That is what lets the “Welcome back” screen (scenario 8) ask for nothing but a password — it only ever had a MASKED identifier to show, and could not otherwise complete the login it had just offered. It grants nothing: the password is still required, and a missing / garbage / expired / revoked cookie with no identifier answers the SAME uniform 401 invalid_credentials as a wrong password (never a 422 “identifier is required”, which would itself confirm the cookie was rejected). An explicit identifier always takes precedence.
emailbodystringconditionalyou choose
DEPRECATED ALIAS for identifier, still accepted and normalised server-side so existing clients keep working. Send identifier in new integrations. Valid email, at most 320 chars.
clientIdbodystringoptionalyou choose
RESERVED and currently REJECTED with 422. The field exists so per-app tokens can land later without a route change — leave it out.
passwordbodystringrequiredyou choose
The account password the user types. 1-1024 chars. Verified against the stored credential; wrong password yields a uniform invalid_credentials 401.
→ POST https://api.kerja.team/v1/auth/login
↩︎Response guide — what comes back & what each value means23 fields

200 OK, { data } envelope, Cache-Control: no-store. A 200 here is a response you must READ, not a status you may trust: FIVE outcomes share it, and the login response itself is the only way to learn which applies (there is no pre-login MFA-status endpoint, by anti-enumeration design): (1) tokens — nothing more to prove; (2) a CHALLENGE CHAIN { challengeToken, challenge, remaining } when a device and/or MFA hop is pending, continued at POST /v1/auth/login/challenge/verify; (3) the LEGACY single-hop { mfaRequired, method, phoneHint, mfaToken } when DEVICE_TRUST_ENABLED is off; (4) NO ORGANIZATION { noOrganization: true, bootstrapToken, expiresIn } — the credential is good but the caller belongs nowhere (§136, scenario 15), continue at POST /v1/auth/organizations/bootstrap; (5) INVITATION PENDING { invitationPending: true, acceptToken, expiresIn, invitations[] } — every membership the caller holds is an unaccepted invitation (§139, scenario 52), continue at POST /v1/auth/invitations/accept. Branches 4 and 5 mint NOTHING session-shaped: no session row, no refresh or device cookie, no access token, no tenant_id claim. Only the token branch sets Set-Cookie: refresh_token. A caller whose only membership is suspended (or a mix of suspended and invited) is offered neither sessionless branch and still gets 403.

data.accessTokenstringconditional
Non-MFA branch only (MFA disabled). The signed JWT access token; the client sends it as Authorization: Bearer for subsequent calls.
data.tokenTypestringconditional
Non-MFA branch only. Token scheme to use in the Authorization header.
  • Bearer — Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Token branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600) — refresh before it elapses. No-organization / invitation-pending branches: the lifetime of the bootstrapToken / acceptToken instead — use it before it lapses, or log in again for a fresh one.
data.user.idstringconditional
Non-MFA branch only. The authenticated user's unique id.
data.user.emailstringconditional
Non-MFA branch only. The authenticated user's email.
data.user.displayNamestringconditional
Non-MFA branch only. Human-readable display name for the user, e.g. for UI greeting.
data.user.tenantIdstringconditional
Non-MFA branch only. The selected tenant for this session; if the user has multiple tenants, the default membership is chosen.
data.challengeTokenstringconditional
CHALLENGE branch only. The token that carries the ordered remaining plan and the accumulated amr — the server keeps no per-chain state. Copy it into POST /v1/auth/login/challenge/verify (or .../resend). Short-lived: LOGIN_CHALLENGE_TTL_SECONDS, default 300s.
data.challenge.typeenumconditional
CHALLENGE branch only. Which factor the first hop collects. A device hop always comes before an MFA hop, so the MFA hint is never leaked to an unrecognised machine.
  • device_email_code — New browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.
  • device_phone_otp — New browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.
  • mfa_phone_otp — An enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.
  • mfa_totp — An enrolled authenticator app — nothing is sent; the code comes from the app, so no resend applies.
data.challenge.hintstringconditional
CHALLENGE branch only. The masked destination the code went to (b••••@example.com, +••••••6789) — show it so the user knows which inbox or handset to check.
data.challenge.expiresInnumberconditional
CHALLENGE branch only. Seconds until this hop expires; when it lapses, start again at login.
data.remainingnumberconditional
CHALLENGE branch only. How many hops are still to clear, counting this one: 1 for a lone device or MFA challenge, 2 for the new-device-plus-MFA chain (LN3, scenario 13).
data.methodenumconditional
LEGACY MFA branch only. Which second factor to collect.
  • phone_otp — Send/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.
  • totp — Read the code from the authenticator app — there is nothing to send, so skip the otp/request step.
data.phoneHintstringconditional
LEGACY MFA branch, phone_otp only. The masked destination the second-factor OTP will go to.
data.mfaRequiredbooleanconditional
MFA branch only (MFA enabled). Signals no token was issued and the caller must complete the MFA challenge via POST /v1/auth/mfa/verify (§3.8).
  • true — MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional
MFA branch only. A single-purpose JWT (~5 min TTL), NOT an access token. The client copies it into the follow-up MFA verify (and OTP request) calls to complete login.
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.
POST/v1/me/step-upauthStart a step-up for phone_change

Proves the CURRENT password and answers { challengeId, stepUpToken, assurance: password, expiresIn, passwordOnlyAllowed, options[], loginChannel? } — both ids are captured and filled into the cards below. The token is single-use and bound to this user, this SESSION, this action and the assurance it reached (STEP_UP_TOKEN_TTL_SECONDS, default 300); spending it on another action, from another session, or twice is 401 step_up_required. passwordOnlyAllowed says whether this password token already suffices; when false, prove one of options[] (a code to a verified, non-young CURRENT value — never a pending one — a TOTP, or a recovery code) for a strong token. Errors: wrong password 401 step_up_failed (counted per user AND per session — 5 in 15 min → 429, and a second throttle trip within 24 h revokes the session); 403 without an acting session (operator / act-as contexts have none); 409 no_password (GET /v1/me → hasPassword: false — set one through the reset flow, scenario 34); 409 change_in_progress when a live change blocks this action (details[0].blocking). Adding a phone spends a phone_change token like every credential change; the code to the new number proves possession of it, the token proves the caller. On a fresh email account with no TOTP the password token already suffices — go straight to the phone change.

⛓ Needs an out-of-band value?
🔐 Step-upSend to open a challenge — its challengeId and token are captured for the cards below
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Starts a step-up (E-1) for ONE named profile action: proves the CURRENT password and answers 200 { challengeId, stepUpToken, assurance: password, expiresIn, passwordOnlyAllowed, options[], loginChannel? }. Every profile mutation — display name, login mode, email / phone / password change, phone removal, MFA disable, recovery-code regeneration — spends one of these tokens; a live session alone is never enough. Auth + throttled + no-store; the body is .strict(). THE TESTER AUTO-CAPTURES BOTH IDS: challengeId is filled into the …/code and …/verify cards on this page, and stepUpToken into the card that spends it. The token is single-use and bound to this user, this SESSION, this action and the assurance it reached (STEP_UP_TOKEN_TTL_SECONDS, default 300); spending it on another action, from another session, or twice → 401 step_up_required. passwordOnlyAllowed says whether this password token already suffices for the action; when false, continue with …/code (for a code factor) and …/verify for a strong token. options[] are the factors …/verify will accept: a code factor always names the CURRENT value on the account — never a pending one — and only when it is verified and not young (applied with password assurance inside CHANNEL_TRUST_AGE_SECONDS, default 30 days); totp appears when a confirmed authenticator exists, recovery_code while unused codes remain. The challenge lives STEP_UP_CHALLENGE_TTL_SECONDS (default 600) and takes STEP_UP_MAX_ATTEMPTS (default 5) wrong codes. Errors: 401 step_up_failed (wrong password — counted per user AND per session, 5 in 15 min → 429, a second throttle trip within 24 h revokes the session) · 403 without an acting session (operator / act-as contexts have none) · 409 no_password (GET /v1/me → hasPassword: false; set one through the reset flow, scenario 34) · 409 change_in_progress when a live change blocks the action (details[0].blocking lists { kind, status }) · 409 login_channel_unavailable (password_change with no verified loginMode channel). Audited step_up_succeeded / step_up_failed.

actionbodyenumrequiredyou choose
The ONE profile action this step-up is for. You pick it to match the card that will spend the token — a token minted for one action is refused by every other (401 step_up_required). The concurrency rules run HERE too, so a blocked action is refused before a token is minted.
  • display_name — For PUT /v1/me/display-name (scenario 28). passwordOnlyAllowed: true — the password token is enough. Never blocked by a live change.
  • email_change — For POST /v1/me/email-change (scenario 30). passwordOnlyAllowed: true on an account WITHOUT confirmed TOTP; false with one, and for a second contact change beside a strong one. A PASSWORD-assurance change is HELD 3 days after its code and sends notices with a cancel link; a STRONG one applies at once. Blocked (409 change_in_progress) by a live email or password change, and beside a live phone change unless both are strong.
  • phone_change — For POST /v1/me/phone-change (scenario 31, and the link-mode add of scenario 4). Same rules as email_change: password enough without confirmed TOTP; held vs applied by assurance; one live phone change at a time, never beside a password change, beside an email change only when both are strong.
  • password_change — For POST /v1/me/password-change (scenario 35). passwordOnlyAllowed: true. The answer carries loginChannel — the CURRENT loginMode channel the confirmation code will go to, which must be verified (else 409 login_channel_unavailable). Blocked by, and blocks, any live contact change.
  • login_mode — For PUT /v1/me/login-mode (scenario 33). passwordOnlyAllowed: false — needs a STRONG token. Blocked while ANY credential change is live.
  • phone_remove — For DELETE /v1/me/phone (no §2 scenario walks this any more — the doc dropped “Removing a phone credential” at its 2026-09-27 revision, while §3.31 and this action stay live). passwordOnlyAllowed: false — needs a STRONG token. Blocked while ANY credential change is live.
  • mfa_disable — For DELETE /v1/me/mfa (scenario 27). passwordOnlyAllowed: false — needs a STRONG token. Never blocked by a live change.
  • mfa_recovery_codes — For POST /v1/me/mfa/recovery-codes (scenario 27). passwordOnlyAllowed: false — needs a STRONG token. Never blocked by a live change.
currentPasswordbodystringrequiredyou choose
Your CURRENT account password, 1–1024 chars — the one place in the §153 flows the current password travels; no change body ever carries it again. Wrong → 401 step_up_failed (throttled per user and per session, see above). A phone-only account with no password gets 409 no_password — set one via the reset flow (scenario 34) first. If you do not know the seeded password, the “Set a known password” helper sets one.
Bearer token
→ POST https://api.kerja.team/v1/me/step-up
↩︎Response guide — what comes back & what each value means12 fields

200 OK, { data } envelope, Cache-Control: no-store. The current password was proven for ONE named action, and this is the PASSWORD-assurance step-up token plus the factors that can raise it to strong. Every profile mutation (display name, email / phone / password change, login mode, phone removal, MFA disable, recovery codes) spends one of these tokens — a live session alone is never enough. The tester captures data.challengeId and data.stepUpToken and fills them into the next cards. 401 step_up_failed = wrong password (5 in 15 min → 429; a second throttle trip in 24 h revokes the session); 409 no_password / change_in_progress / login_channel_unavailable refuse BEFORE a token is minted.

data.challengeIdUUIDalways
The step-up challenge. Path parameter of POST /v1/me/step-up/{challengeId}/code and /verify. Lives STEP_UP_CHALLENGE_TTL_SECONDS (600) and takes STEP_UP_MAX_ATTEMPTS (5) wrong codes.
data.stepUpTokenstring (43-char opaque)always
Single-use token bound to this user, this SESSION, this action and password assurance. Spend it on the action's route when passwordOnlyAllowed is true; spending it on another action, from another session or twice → 401 step_up_required. It stays valid alongside the strong token /verify later mints.
data.assuranceenumalways
What the token proves.
  • password — Always this value here — only the current password has been proven. A strong token comes only from POST /v1/me/step-up/{challengeId}/verify.
data.expiresInnumberalways
Token lifetime in seconds (STEP_UP_TOKEN_TTL_SECONDS, default 300).
data.passwordOnlyAllowedbooleanalways
Whether this token, as minted, already suffices for the action.
  • true — Spend stepUpToken now: display_name and password_change always; a contact change on an account without confirmed TOTP (that change is then HELD after its code). Raising to strong is still possible and makes a contact change apply at once.
  • false — Password is not enough: login_mode, phone_remove, mfa_disable, mfa_recovery_codes, and a second contact change beside a strong one. Continue with /code (for a code factor) and /verify to get a strong token.
data.optionsarrayalways
The factors /verify will accept for this challenge. A code factor always goes to the CURRENT value on the account (never a pending one) and only if that value is verified and not young (applied on password assurance inside CHANNEL_TRUST_AGE_SECONDS, 30 days). For a contact change the channel being changed is one option among the others, never the only one.
data.options[].factorenumalways
The factor kind — pass it as factor to /code and /verify.
  • email_code — A code to the verified current email: send it with /code, then /verify.
  • phone_code — A WhatsApp/SMS code to the verified current phone: send it with /code, then /verify.
  • totp — Offered when a confirmed authenticator exists. Nothing to send — go straight to /verify with the app's code.
  • recovery_code — Offered while unused recovery codes remain. Nothing to send — /verify with one; it is consumed.
data.options[].roleenumconditional
Code factors only: how the channel relates to the action. The list holds whatever is ELIGIBLE and nothing more — an account whose only eligible factor is the channel being changed is offered that alone, and one with no eligible factor at all is offered an EMPTY list and must proceed password-only (accepting the three-day hold on a contact change).
  • current_value — The channel whose value this very action changes (e.g. the current email in an email change) — proving it confirms the owner of the old value.
  • other_channel — The account's OTHER verified channel — proving it shows control of a second, independent credential.
  • channel — The role used for the actions that change no channel value of their own — login_mode, phone_remove and the two MFA actions: every eligible channel is simply offered as a channel. (For phone_remove the phone is still offered as current_value, because that IS the value being removed.)
data.options[].maskedstringconditional
Code factors only: the masked destination (b•••@old.id, +62 812••••21) so the user can choose. Absent for totp / recovery_code.
data.loginChannelobjectconditional
password_change only: where the confirmation code of POST /v1/me/password-change will go — the current value of the loginMode channel, which must be verified (else 409 login_channel_unavailable).
data.loginChannel.channelenumconditional
The sign-in channel.
  • email — The password-change code will be emailed.
  • phone — The password-change code will go to the phone by WhatsApp/SMS.
data.loginChannel.maskedstringconditional
The masked destination — show it before the user submits the new password.
POST/v1/me/step-up/{challengeId}/codeauthSend a step-up code to one of the offered channels

Only for a CODE factor: factor is email_code or phone_code and must be one of the challenge's options[] (else 422 factor_not_eligible). 202 { status, masked, ttlSeconds }. The code is BOUND to this challenge, and a second send retires the first. Skip this card for a TOTP or a recovery code — they go straight to verify. 404 for an unknown or expired challenge (STEP_UP_CHALLENGE_TTL_SECONDS, default 600); 429 on the per-user, per-channel send throttle.

🔐 Step-upno challenge yet — start the step-up on the card above
Path parameters
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Sends a step-up code (E-2) to ONE of the challenge's channel options — the step_up.<action> message, in the caller's locale. 202 { status: accepted, masked, ttlSeconds }. The code is bound to THIS challenge, and a second send retires the first. Each code sent takes STEP_UP_MAX_ATTEMPTS (default 5) wrong guesses — email and phone alike — after which that CODE is burnt and a resend is needed; the challenge itself is separately capped at 5 failed proofs of any factor code. Auth + throttled (per user, per channel → 429) + no-store; the body is .strict(). Only needed for a code factor — a totp or recovery_code factor goes straight to …/verify. 404 when there is no such (live) challenge for the caller; 422 factor_not_eligible when the factor is not among the challenge's options.

challengeIdpathUUIDrequiredauto-captured
The challenge POST /v1/me/step-up just opened — auto-filled from the step-up card on this page (a fresh start supersedes it). Expired, unknown, or another user's → 404 not_found.
factorbodyenumrequiredfrom a response
Which channel to send the code to. Must be one of the challenge's options[] from the step-up answer (else 422 factor_not_eligible) — a code always goes to the CURRENT, verified, not-young value, never to a pending one. For a contact change the channel being changed is one option among the others, never the only one.
  • email_code — Emails a code to the account's current verified address (~15 min). Retrieve it with the “Get a step-up code” helper (by challengeId).
  • phone_code — Sends a WhatsApp/SMS code to the account's current verified number (~5 min). Retrieve it with the “Get a step-up code” helper (by challengeId).
Bearer token
→ POST https://api.kerja.team/v1/me/step-up/{challengeId}/code
↩︎Response guide — what comes back & what each value means3 fields

202 Accepted, { data } envelope, Cache-Control: no-store. A step-up code (the step_up.<action> message, in the caller's locale) went to the chosen channel option. The code is bound to THIS challenge; a second send retires the first code. 404 = no such challenge for this caller (or it expired); 422 factor_not_eligible = the factor is not among the challenge's options; 429 = per-user, per-channel throttle.

data.statusenumalways
Send acknowledgement.
  • accepted — The only value: the code is on its way. Collect it (dev: the step-up OOB helper) and call /verify with the same factor.
data.maskedstringalways
The masked destination the code went to — tell the user which inbox / handset to check.
data.ttlSecondsnumberalways
Seconds the code lives (e.g. 300). Past it, send again.
POST/v1/me/step-up/{challengeId}/verifyauthProve the factor → a STRONG token

factor is email_code · phone_code · totp · recovery_code (one of the challenge's options), code is the value that proves it. 200 { stepUpToken, assurance: strong, expiresIn } — captured, and it REPLACES the password token in the card that spends it. The password token stays valid alongside it. A used recovery code is consumed. Wrong, expired or exhausted code → 401 step_up_failed; the challenge takes STEP_UP_MAX_ATTEMPTS (default 5) wrong codes and closes once it succeeds or is exhausted.

⛓ Needs an out-of-band value?
🔐 Step-upno challenge yet — start the step-up on the card above
Path parameters
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from3 fields

Proves a factor (E-3) — the code from …/code, a TOTP, or a recovery code — and answers 200 { stepUpToken, assurance: strong, expiresIn } for the SAME action. THE TESTER CAPTURES IT and it REPLACES the password token in the card that spends it; the password token from the start stays valid alongside it. The challenge closes once it succeeds or its attempts are exhausted (STEP_UP_MAX_ATTEMPTS, default 5). Auth + no-store; .strict(). Errors: 401 step_up_failed (wrong, expired or exhausted code; a TOTP or recovery code that did not verify; and the CHALLENGE as a whole takes STEP_UP_MAX_ATTEMPTS (default 5) failed proofs of ANY factor, after which it is DELETED and every later call answers 404) · 404 no such challenge · 422 factor_not_eligible. A used recovery code is consumed. Audited step_up_succeeded with stepUpMethod = the factor.

challengeIdpathUUIDrequiredauto-captured
The challenge from POST /v1/me/step-up — auto-filled from the step-up card on this page. Must be the same challenge the code was sent for (…/code binds a code to ONE challenge).
factorbodyenumrequiredfrom a response
Which factor code carries. Must be one of the challenge's options[] (else 422 factor_not_eligible).
  • email_code — code is the email code sent by …/code with factor email_code. Send …/code first — the “Get a step-up code” helper REPLACES the hash of the code row that send created, so there is nothing to fetch before it.
  • phone_code — code is the WhatsApp/SMS code sent by …/code with factor phone_code. Same helper, same order: send, then “Get a step-up code”.
  • totp — code is the current 6-digit TOTP from the enrolled authenticator — use the “Get current TOTP code” helper. Offered only when a CONFIRMED authenticator exists. No …/code send.
  • recovery_code — code is one of your unused MFA recovery codes (from enrolment or the last regeneration). Offered only while unused codes remain; CONSUMED on success. No …/code send.
codebodystringrequiredout-of-band
The value that proves the chosen factor, trimmed, 1–32 chars: the email / phone code delivered out-of-band (locally via the “Get a step-up code” helper, by challengeId), a live TOTP (“Get current TOTP code”), or a recovery code you saved. Wrong, expired or exhausted → 401 step_up_failed, and each wrong code spends one of the challenge's attempts.
Bearer token
→ POST https://api.kerja.team/v1/me/step-up/{challengeId}/verify
↩︎Response guide — what comes back & what each value means3 fields

200 OK, { data } envelope, Cache-Control: no-store. A second factor was proven (the /code code, a TOTP, or a recovery code — the latter consumed) and this is a STRONG step-up token for the same action. The challenge is closed once it succeeds or its attempts run out. The tester captures data.stepUpToken, which replaces the password-assurance token in the next cards (that earlier token stays valid alongside it). 401 step_up_failed = wrong / expired / exhausted code. Audits step_up_succeeded with stepUpMethod = the factor.

data.stepUpTokenstring (43-char opaque)always
The strong token — single-use, bound to this user, session and action. Spend it on the action's route: required for PUT /v1/me/login-mode, DELETE /v1/me/phone, DELETE /v1/me/mfa and POST /v1/me/mfa/recovery-codes; on an email / phone change it makes the change APPLY at its code rather than be held.
data.assuranceenumalways
What the token proves.
  • strong — Always this value here: password plus a second factor. Satisfies every step-up-gated action it was minted for.
data.expiresInnumberalways
Token lifetime in seconds (STEP_UP_TOKEN_TTL_SECONDS, default 300).
POST/v1/me/phone-changeauthRequest a phone change (code to the NEW number)

An ADD over a blank or unverified number, or a REPLACEMENT — the phone twin of the email change (scenario 30). stepUpToken is a phone_change token (password or strong), filled in from the step-up above. 202 with the CredentialChange row (status awaiting_verification); a WhatsApp/SMS code (contact_change.phone, 5 min) goes to the NEW number. NOT policy-gated — combinationMode does not block /v1/me credential changes. 409 conflict if the number belongs to another live user (numbers are globally unique) or an Idempotency-Key is reused with another body; 409 change_in_progress under the concurrency rules (one live change per kind; email beside phone only when BOTH are strong; never beside a password change); 409 no_password; 422 unchanged when it is already your verified number; 429 over CREDENTIAL_CHANGE_MAX_PER_DAY (default 5). A PASSWORD-assurance request also sends contact_change_requested to every verified current channel, carrying a sessionless cancel link (scenario 36).

🔐 Step-upno token yet — start the step-up above
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from3 fields

Requests a phone change (E-9) — an ADD over a blank or unverified number, or a REPLACEMENT. The phone twin of POST /v1/me/email-change: same token rule (phone_change), same concurrency rules, same daily cap, same idempotency header, same CredentialChange response (202, status awaiting_verification). A WhatsApp/SMS code (contact_change.phone, 5 min) goes to the NEW number; the current number (if any) keeps signing in until the change applies. A password-assurance request also sends contact_change_requested with a sessionless cancel link to every verified current channel, and its verify is HELD; a strong one applies at verify. NOT gated by the registration policy — combinationMode does not block /v1/me credential changes. Errors: 401 step_up_required · 409 no_password · 409 change_in_progress · 409 conflict (the number belongs to another account — numbers are globally unique — or a reused Idempotency-Key) · 422 unchanged · 429 daily cap. Walk-through: scenario 31 (and the link-mode add of scenario 4).

Idempotency-KeyheaderUUIDoptionalclient-generated
OPTIONAL and durable — the card offers the input empty, and an empty input sends no header. With a fresh UUID, a retry with the same body replays the stored 202; the same key with another body → 409 conflict.
phonebodyE.164requiredyou choose
The NEW number, which receives the code. E.164 (e.g. +15551234567). Already your verified number → 422 unchanged; belongs to another live account → 409 conflict.
stepUpTokenbodystringrequiredauto-captured
A token minted for phone_change — password assurance or strong; which one decides held-vs-applied at verify. Filled in automatically from the step-up cards on this page. A token minted for another action (e.g. login_mode later in scenario 4) is refused: 401 step_up_required.
Idempotency-Key header — optional; empty sends no header
Bearer token
→ POST https://api.kerja.team/v1/me/phone-change
↩︎Response guide — what comes back & what each value means11 fields

202 Accepted, { data } envelope, Cache-Control: no-store — the CredentialChange row (kind phone, status awaiting_verification). The phone twin of POST /v1/me/email-change (scenario 31, and scenario 4 for linking a phone after email sign-up): an add over a blank or unverified number, or a replacement. A WhatsApp/SMS code (contact_change.phone, 5 min) goes to the NEW number. Same phone_change step-up token rule, same concurrency rules, same daily cap, same Idempotency-Key replay, same refusals (401 step_up_required, 409 no_password / change_in_progress / conflict, 422 unchanged, 429).

data.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.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.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.statusenumalways
Where the change stands — the field that decides the client's next step.
  • 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.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.requestedAtISO-8601always
When the change was requested.
data.verifiedAtISO-8601 | nullalways
When the code was confirmed; null while awaiting_verification and on a change that never got that far.
data.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.expiresAtISO-8601always
When an unconfirmed change lapses to expired (requestedAt + CONTACT_CHANGE_PENDING_TTL_SECONDS, 24 h).
data.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.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.
POST/v1/me/phone-change/verifyauthConfirm the code — apply now (strong) or hold (password)

The code sent to the NEW number (4–8 digits). What happens depends on the assurance the REQUEST spent: STRONG → applied now — phone + phoneVerifiedAt are set, user.upserted is projected once per app across your tenants, and a phone_otp MFA method MOVES WITH THE NUMBER (the other channel is told, mfa_phone_moved). PASSWORD → the change is HELD for CONTACT_CHANGE_HOLD_SECONDS (default 3 days): every verified current channel is told when it takes effect and gets the same cancel link, and worker job J7 applies it at effectiveAt. 200 with the row, status applied or held. 401 invalid_code (the 5th wrong code burns it — resend restores); 409 no_pending_change; 409 conflict when the number was taken meanwhile (the change is failed). Never mints a session or device trust.

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

Confirms the code sent to the NEW number (E-10): STRONG applies now, PASSWORD holds for 3 days — exactly as the email twin. On apply phone and phoneVerifiedAt are set, user.upserted is projected across tenants, and a phone_otp MFA method MOVES WITH THE NUMBER (the other channel is told with mfa_phone_moved). 200 with the CredentialChange row, applied or held. Never mints a session or grants device trust. Holding a verified phone does NOT change how you sign in — loginMode stays until PUT /v1/me/login-mode (scenario 33). Errors: 401 invalid_code (5th wrong code burns it) · 409 no_pending_change · 409 conflict (the number was taken meanwhile — the change is failed).

codebodystringrequiredout-of-band
The 4–8 digit WhatsApp/SMS code sent to the NEW number by POST /v1/me/phone-change (or the latest resend), ~5 min. Locally, use the “Get a credential-change code” helper with your userId and kind phone (a held or closed change has no code → 404).
Bearer token
→ POST https://api.kerja.team/v1/me/phone-change/verify
↩︎Response guide — what comes back & what each value means11 fields

200 OK, { data } envelope, Cache-Control: no-store — the CredentialChange row, applied or held exactly as POST /v1/me/email-change/verify: a strong request applies now, a password-assurance one is held 3 days (current number keeps signing in, cancel link resent in contact_change_held). On apply, phone and phoneVerifiedAt are set, user.upserted is projected across tenants, and a phone_otp MFA method MOVES with the number (the other channel is told with mfa_phone_moved). Never mints a session. 401 invalid_code, 409 no_pending_change, 409 conflict (number taken meanwhile → failed).

data.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.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.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.statusenumalways
Where the change stands — the field that decides the client's next step.
  • 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.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.requestedAtISO-8601always
When the change was requested.
data.verifiedAtISO-8601 | nullalways
When the code was confirmed; null while awaiting_verification and on a change that never got that far.
data.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.expiresAtISO-8601always
When an unconfirmed change lapses to expired (requestedAt + CONTACT_CHANGE_PENDING_TTL_SECONDS, 24 h).
data.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.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.
PUT/v1/me/login-modeauthOptional — make the phone the primary channel

Step 5, and optional — walked through in full at scenario 33. It needs its OWN step-up: a STRONG token minted for login_mode, not the phone_change one above (a token is bound to one action). Holding both credentials does NOT by itself change how you log in — the account keeps its login_mode until you change it here.

🔐 Step-upno token yet — start the step-up above
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Switches the PRIMARY sign-in channel (E-18) — which sign-in form applies and, more importantly, which channel a new-device challenge is delivered on — on a STRONG login_mode step-up token. Auth + no-store; .strict(). 200 with the profile. The TARGET channel must be verified (409 conflict otherwise — else you could lock yourself out of your own new-device challenge); refused 409 change_in_progress while ANY credential change is live (details[0].blocking, scenario 36); 422 unchanged when the account already signs in that way AND that channel is verified — the token is still spent. CONFLICT IS DECIDED FIRST: an account already on a channel that is no longer verified is told 409 conflict, not 422 unchanged, because the usability of the target is what the caller has to fix. Audited login_mode_changed and both verified channels receive a login_mode_changed notice. Nothing is projected; sessions and device trust are untouched. Walk-through: scenario 33 (and the optional last step of scenario 4).

loginModebodyenumrequiredyou choose
The channel the account should sign in with from now on.
  • email — Sign in with email + password (scenario 9); a new-device challenge arrives as an email code (device_email_code), and a password change's confirmation code goes to the email. Refused 409 without a verified email. Switch here BEFORE removing a phone that is the primary channel (§3.31, which no §2 scenario walks any more).
  • phone — Sign in with the E.164 number + password (scenario 10); a new-device challenge arrives as a phone OTP (device_phone_otp), and a password change's code goes to the phone. Refused 409 without a verified phone.
stepUpTokenbodystringrequiredauto-captured
A STRONG token minted for login_mode — the password token from the start card is refused (401 step_up_required), because flipping the channel changes which challenge a future attacker faces and a stolen session could otherwise redirect the new-device code to a channel it controls. Filled in automatically from the step-up cards on this page once …/verify has upgraded it; a token minted for phone_change in the same scenario will NOT do — it is bound to its own action.
Bearer token
→ PUT https://api.kerja.team/v1/me/login-mode
↩︎Response guide — what comes back & what each value means24 fields

200 OK, { data } envelope, Cache-Control: no-store — the PROFILE, the same shape as GET /v1/me, with the new loginMode. Spends a STRONG login_mode step-up token (scenario 33; a password-assurance token is 401 step_up_required). The target channel must be verified (409 conflict otherwise) and NO credential change may be live (409 change_in_progress); asking for the mode already set is 422 unchanged and still spends the token. Both verified channels receive a login_mode_changed notice; nothing is projected; sessions and device trust are untouched. Audits login_mode_changed.

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.