Signing up with email + phone (link mode)
Signing upA 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.
- Sign up with email (scenario 2, steps 1–2) — verification returns the session, so the caller is authenticated
- POST /v1/me/phone/otp/request → 202, OTP to the phone being linked (purpose=link)
- POST /v1/me/phone → phone + code (OTP only, no password) → the account now holds both factors
- PATCH /v1/me → optional: switch which credential is primary (scenario 31; step-up required)
/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.
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 choosepasswordbodystringrequiredyou choosephonebodystringconditionalyou choosecodebodystringconditionalout-of-banddisplayNamebodystringoptionalyou chooseorganizationNamebodystringoptionalyou choosePOST https://api.kerja.team/v1/auth/registerResponse 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.statusenumalwaysaccepted— The 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).
/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 {}.
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-bandemailbodystringconditionalyou choosecodebodystringconditionalout-of-bandPOST https://api.kerja.team/v1/auth/email/verifyResponse 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.accessTokenstringconditionaldata.tokenTypestringconditionaldata.expiresInnumberconditionaldata.userobjectconditionalSet-Cookie: refresh_tokencookieconditional/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).
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 chooseemailbodystringconditionalyou chooseclientIdbodystringoptionalyou choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/loginResponse 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.accessTokenstringconditionaldata.tokenTypestringconditionalBearer— Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditionaldata.user.idstringconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdstringconditionaldata.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_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.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.methodenumconditionalphone_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.phoneHintstringconditionaldata.mfaRequiredbooleanconditionaltrue— MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional/v1/me/phone/otp/requestauthRequest phone-link OTPpurpose=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).
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 choosePOST https://api.kerja.team/v1/me/phone/otp/requestResponse 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.statusenumalwaysaccepted— The challenge was accepted and the OTP dispatched out-of-band; this is the only value ever returned on a 202 success.
/v1/me/phoneauthVerify OTP & set phoneOTP-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).
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 choosecodebodystringrequiredout-of-bandPOST https://api.kerja.team/v1/me/phoneResponse 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.idUUIDalwaysdata.phonestringalwaysdata.phoneVerifiedAtISO-8601always/v1/meauthOptional — make the phone the primary channelStep 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.
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 chooseemailbodystringconditionalyou chooseloginModebodyenumconditionalyou chooseemail— Sign in with email + password (scenario 9); a new-device challenge arrives as an email code (device_email_code). Refused 409 without a verified email.phone— Sign 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 chooseotpCodebodystringconditionalout-of-bandPATCH https://api.kerja.team/v1/meResponse 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.idUUIDalwaysdata.emailstringalwaysdata.displayNamestringalwaysdata.hasMfaEnabledbooleanalwaystrue— User has MFA enabled; step-up/MFA challenges apply on sensitive flows.false— User has no MFA factor enrolled.
data.emailVerifiedAtISO-8601always