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 link the other credential. The linking step 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/phone/otp/request → 202, OTP to the phone being linked (purpose=link)
  3. POST /v1/me/phone → phone + code (OTP only, no password) → the account now holds both factors
  4. PATCH /v1/me → optional: switch which credential is primary (scenario 31; step-up required)
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 means1 field

202 Accepted with the standard { data } envelope; body is the uniform anti-enumeration acknowledgement { "data": { "status": "accepted" } } — byte-for-byte identical whether or not the email already existed. No tokens, no user object, and no notable response headers (no Set-Cookie; the email-verify token is delivered out-of-band, never in the body).

data.statusenumalways
Acknowledgement that the registration request was accepted for asynchronous processing. The client treats this as 'check your email' — the account cannot log in until the out-of-band email-verify token is used (§3.12). This value is returned identically for both new and already-existing emails (anti-enumeration), so the client must not infer account existence from it.
  • acceptedThe only possible value; signals the 202 request was accepted for processing (tenant/admin provisioning or a 'you already have an account' notice happens out-of-band).
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 {}.

⛓ 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). Submit EITHER the link token OR the short code — they are two representations of ONE verification_tokens row, so redeeming either marks the row used and kills the other. Two paths land here: first-time verification (registration, or the atomic sign-up's outstanding email), and a STAGED email change, where the pending_email is swapped in to become the active verified address.

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 after staging a new email (scenario 28) and paste it here. Supply this OR { email, code }, never both — the first one wins and the second returns 401. 1–512 chars.
emailbodystringconditionalyou choose
The address being verified. Required when redeeming by short code (the code alone does not identify the row); omit it when submitting the link token.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the same message as the link, 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.
POST https://api.kerja.team/v1/auth/email/verify
↩︎Response guide — what comes back & what each value means5 fields

200 OK with the { data } envelope. The 200 means the challenge (link OR code — one row, either half) was valid and consumed: the email is marked verified, the account is set active, and a user.upserted outbox event is enqueued. SINCE ENHANCEMENT 4 VERIFICATION ENDS IN A SESSION: when the account already has an active membership the body carries an accessToken and a refresh_token cookie (amr [email]), so the old verify-then-log-in round-trip is unnecessary. This was an ADDITION to the existing 200 — an account with NO active membership (e.g. an invitee) still receives {}, so no existing caller breaks. A staged email change instead swaps the pending_email in as the active verified address.

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.
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 }. Three possible answers: { accessToken, … } (nothing left to prove — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — continue at scenario 11/12/13), or the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12). 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. The answer is one of three shapes — tokens, a challenge chain, or the legacy MFA challenge — and 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 means16 fields

200 OK, { data } envelope, Cache-Control: no-store. THREE possible shapes, and the login response itself is the only way to learn which branch 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. Only the token branch sets Set-Cookie: refresh_token.

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.
  • BearerSend the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Non-MFA branch only. Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Client should refresh before it elapses.
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_codeNew browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.
  • device_phone_otpNew browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.
  • mfa_phone_otpAn enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.
  • mfa_totpAn 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_otpSend/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.
  • totpRead 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).
  • trueMFA 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.
POST/v1/me/phone/otp/requestauthRequest phone-link OTP

purpose=link. Always 202. NOT policy-gated — combinationMode does not block /v1/me credential linking. 409 if the phone already belongs to another live user (numbers are globally unique).

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

Begins a phone-link challenge (purpose=link) for the authenticated caller; always returns 202. Beyond the tester-supplied Authorization and x-gateway-context, the only caller input is the phone number in the body. No Idempotency-Key or step-up is required for this request step.

phonebodyE.164requiredyou choose
The phone number to link, typed by the user in E.164 format (e.g. +15551234567). The server sends an OTP to this number to begin the link challenge; it must match the phone later submitted to POST /v1/me/phone.
Bearer token
POST https://api.kerja.team/v1/me/phone/otp/request
↩︎Response guide — what comes back & what each value means1 field

Always 202 Accepted with the standard { data } envelope; the body is a fixed acknowledgement { data: { status: "accepted" } } carrying no OTP material. No notable response headers (no Set-Cookie, no Cache-Control: no-store).

data.statusenumalways
Fixed acknowledgement that the phone-link OTP challenge (purpose=link) was accepted and an otp.requested event emitted. The actual code is delivered out-of-band (SMS); the client proceeds to POST /v1/me/phone with the received code.
  • acceptedThe challenge was accepted and the OTP dispatched out-of-band; this is the only value ever returned on a 202 success.
POST/v1/me/phoneauthVerify OTP & set phone

OTP-only: the phone OTP is the sole possession proof and no password step-up is required (the session already authorized the caller); the body is .strict() so any extra field (e.g. currentPassword) → 422. Sets phone + phoneVerifiedAt. Holding both credentials does NOT by itself change how you log in — the account keeps its login_mode until you change it explicitly (scenario 31).

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

Verifies the SMS OTP and sets the caller's phone (phoneVerifiedAt), enforcing global phone uniqueness. Auth required (the tester + gateway supply Authorization / x-gateway-context). OTP-only (no password step-up): the phone OTP is the sole proof of possession — a deliberate deviation from the step-up-on-credential-change rule (AUTH-IAM-01) by product decision. JSON body is .strict(), so any extra field (e.g. currentPassword) → 422. No Idempotency-Key required.

phonebodyE.164requiredyou choose
The phone number the user is linking, in E.164 format. Must match the number used in the prior §3.29 OTP challenge. The user types this.
codebodystringrequiredout-of-band
The numeric one-time OTP the user received via SMS on the new phone. Obtained from the SMS side channel and pasted in; it proves possession of the new phone.
Bearer token
POST https://api.kerja.team/v1/me/phone
↩︎Response guide — what comes back & what each value means3 fields

200 OK with the standard { data } envelope; data is the updated user profile after the OTP is verified and the phone is linked. Response carries Cache-Control: no-store; no Set-Cookie. No new tokens are issued.

data.idUUIDalways
The caller's user id (the authenticated subject). Confirms which user record was updated.
data.phonestringalways
The newly linked phone number in E.164 format (matches the §3.19 challenge phone). Now usable as a login factor.
data.phoneVerifiedAtISO-8601always
Timestamp when the OTP was verified and the phone became confirmed. Non-null indicates the phone is verified.
PATCH/v1/meauthOptional — make the phone the primary channel

Step 4, and optional: now that BOTH credentials exist, switch which one is primary. Walked through in full at scenario 31. STEP-UP IS MANDATORY — a live session is not enough, because flipping the channel changes which challenge a future attacker faces, so an attacker holding a stolen session could otherwise redirect the new-device code to a channel they control. The target channel must be usable: switching to phone without a verified phone is 409, otherwise a user could lock themselves out of their own new-device challenge with no way back.

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

Updates the current user's own profile. Auth only (gateway context handles Authorization / x-gateway-context). Body is .strict(), which blocks mass assignment — status, roles and the like are not settable here — and at least one updatable field must be present. Two very different edits live on this route: an EMAIL change, which is STAGED (the current verified address keeps working until the new one is confirmed), and a LOGIN MODE change, which is step-up gated.

displayNamebodystringconditionalyou choose
The user freely types their new display name. Required only if email is omitted; at least one of displayName/email must be present. Trimmed, 1-200 chars, NFC-normalized.
emailbodystringconditionalyou choose
The user freely types their new email address. Trimmed, valid email, <=320 chars, NFC-normalized. The change is STAGED, not applied: the address is stored as pending_email and a verify challenge (24h link + 15 min code) is issued to it, while email / emailVerifiedAt / status stay UNCHANGED — so the account is never locked out by a typo and the 200 reflects the current, unchanged address. It becomes the active verified email only when confirmed at POST /v1/auth/email/verify. 409 conflict if it already belongs to another active identity.
loginModebodyenumconditionalyou choose
The account's PRIMARY channel — which sign-in form applies and, more importantly, which channel a new-device challenge is delivered on. Requires step-up (currentPassword or otpCode) and a verified target channel. It is a no-op, needing no step-up, when the mode is already what you asked for. Set at registration (phone sign-up -> phone; email sign-up and every invitation -> email) and changing it does NOT invalidate sessions or device trust.
  • emailSign in with email + password (scenario 9); a new-device challenge arrives as an email code (device_email_code). Refused 409 without a verified email.
  • phoneSign in with the E.164 number + password (scenario 10); a new-device challenge arrives as a phone OTP (device_phone_otp). Refused 409 without a verified phone.
currentPasswordbodystringconditionalyou choose
Step-up factor for a loginMode change, for a password-holding account. Mandatory because flipping the channel changes which challenge a future attacker faces — an attacker holding a stolen session could otherwise redirect the new-device code to a channel they control. A wrong factor -> 401.
otpCodebodystringconditionalout-of-band
Step-up factor for a loginMode change, for a PASSWORD-LESS (phone-only) account: a step_up-purpose OTP obtained from POST /v1/me/mfa/otp/request. Supply currentPassword OR otpCode.
Bearer token
PATCH https://api.kerja.team/v1/me
↩︎Response guide — what comes back & what each value means5 fields

200 OK returning the profile in the standard { data } envelope (same shape as GET /v1/me). READ IT CAREFULLY AFTER AN EMAIL CHANGE: the change is STAGED, so email and emailVerifiedAt still show the CURRENT, unchanged, still-working address — the staged pending_email is deliberately not exposed. It becomes the active verified address only when confirmed at POST /v1/auth/email/verify, which is why a typo'd change is harmless. A loginMode change, by contrast, is applied immediately (audited as login_mode_changed) and is visible in data.loginMode; it invalidates neither sessions nor device trust.

data.idUUIDalways
The authenticated user's unique identifier. Stable across the profile update; use it to correlate the caller's identity in follow-up calls.
data.emailstringalways
The user's current email address, reflecting the update if email was changed. Changing it sets the account unverified until re-verified via the email-verification flow.
data.displayNamestringalways
The user's display name, reflecting the update if displayName was changed. Show this in the tester as the updated profile name.
data.hasMfaEnabledbooleanalways
Whether the user has a verified MFA factor enrolled. Not settable via this endpoint; echoed from the current account state.
  • trueUser has MFA enabled; step-up/MFA challenges apply on sensitive flows.
  • falseUser has no MFA factor enrolled.
data.emailVerifiedAtISO-8601always
Timestamp when the email was verified, or null if unverified. Changing the email via this call resets it to null (a new single-use 24h verify token is issued out-of-band), so the client should prompt the user to re-verify.