Changing your email address
Profile & credentialsThe new address is STAGED as pending_email and a verify challenge (link + code) is sent to it; the current verified email stays active until the new one is confirmed — so a typo never locks the account out. STEP-UP GATED, exactly as scenario 31 is.
- PATCH /v1/me (with email + a step-up factor) → 200; the new address is staged, a challenge goes out, and email / emailVerifiedAt / status are unchanged
- GET /v1/me → optional: re-read at any time to see the change still in flight (pendingEmail is non-null until step 2 completes)
- POST /v1/auth/email/verify → submit the token OR the code to confirm; the new address is then swapped in
/v1/meauthStage a new email (step-up)STEP-UP REQUIRED — the body carries currentPassword OR otpCode ({ ≥ 1 }), exactly as the loginMode change does (scenario 31). A live session alone is NOT sufficient, and a supplied-but-wrong currentPassword is refused rather than ignored (AUTH-IAM-01): since the email is the address password recovery is sent to, an attacker holding a stolen session who could point recovery at an address they control would turn a temporary session theft into permanent ownership of the account. ⚠ DOC DISCREPANCY — §2.28 states this step-up plainly, but §3.20's field table marks currentPassword “required (with otpCode as the alternative) ONLY when loginMode changes” and lists no 401 among its errors. If a bare { email } is accepted with 200 here, the service implements the §3.20 reading and §2.28 is aspirational; the factor is a settable field either way, so sending it can never be the reason a call fails. Report which one the service actually does. The change is STAGED, not applied: the new address goes to pending_email and a single-use verify token (24h) + short code are issued to it out-of-band. The 200 reflects the CURRENT (unchanged) email and emailVerifiedAt, and echoes the staged address as pendingEmail so the UI can name where the link went. 409 conflict if the new email already belongs to another active identity (checked at stage time; a race that claims it before confirmation surfaces as 409 at verify). THE ACCOUNT IS NEVER LOCKED OUT BY AN EMAIL CHANGE — login keeps working on the current verified email until the new one is confirmed; only after confirmation does login require the new address. If the address was a typo, step 2 never happens and the account keeps working on its old email. NO PROJECTION EVENT IS EMITTED: displayName is a projected column (app.users.display_name) and this handler contains no outbox write at all, so a user renaming themselves reaches no app at the request — the reconciler picks it up within PROJECTION_RECONCILE_INTERVAL_MS (default 1h) because the update bumps row_version and the ledger row already exists. An app's copy of a display name is therefore UP TO AN HOUR STALE BY DESIGN, which is a stale row rather than a missing one and needs no repair (scenario 42). The email path is unaffected in practice — the change is staged, and the verify step that activates it does emit user.upserted.
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/v1/meauthOptional — is the change still in flight?Re-read the profile at any time while a change is staged: pendingEmail is non-null until the verify completes, which is how a UI shows a “waiting on ada+new@acme.io — resend?” banner across page loads. The internal columns (password hash, the staged address's token) are never returned.
Field guide — what each value means & where it comes fromno inputs
No request body, path params, query params, or caller-set headers. It only needs the authenticated session: the tester sends the captured Authorization access token and the gateway injects x-gateway-context. Returns the current user's profile (id, email, displayName, hasMfaEnabled, emailVerifiedAt).
GET https://api.kerja.team/v1/meResponse guide — what comes back & what each value means8 fields
200 OK. Body is the standard { data } envelope wrapping the current user's profile object; no notable response headers (no Set-Cookie / Cache-Control). Internal columns such as the password hash are never returned.
data.idUUIDalwaysdata.emailstringalwaysdata.displayNamestringalwaysdata.hasMfaEnabledbooleanalwaystrue— MFA is enabled for this user.false— MFA is not enabled for this user.
data.emailVerifiedAtISO-8601alwaysdata.phonestringalwaysdata.phoneVerifiedAtISO-8601alwaysdata.loginModeenumalwaysemail— Signs in at scenario 9 (email + password); a new-device challenge arrives as an email code.phone— Signs in at scenario 10 (phone + password); a new-device challenge arrives as a phone OTP — which is why unlinking the phone requires switching this off first.
/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/email/verify/resendpublicResend the email challenge (link + code)202 for an address still awaiting confirmation (a not-yet-verified active email OR a staged pending_email); 409 when there is nothing to send (unknown email OR already verified) — an OR-list message that never discloses which. Always re-mints BOTH halves together, so the newest message is internally consistent.
Field guide — what each value means & where it comes from1 field
Public. Re-issues the email challenge for an address that is not yet confirmed, always re-minting BOTH halves together (a fresh 24h link and a fresh 15 min code) so the newest message is internally consistent. Per-IP throttle, fail-closed, mirroring registration.
emailbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/email/verify/resendResponse guide — what comes back & what each value means1 field
202 Accepted with a uniform body when there IS something to send — an unconfirmed active email or a staged pending_email. Nothing to send (unknown address, or an already-verified address that is not a staged pending one) → 409 with an OR-list message that does not disclose which applied. Per-IP throttle; a 429 carries Retry-After.
data.statusstringalways