switching clears the token & responses
No token
28

Changing your email address

Profile & credentials

The 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.

  1. 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
  2. GET /v1/me → optional: re-read at any time to see the change still in flight (pendingEmail is non-null until step 2 completes)
  3. POST /v1/auth/email/verify → submit the token OR the code to confirm; the new address is then swapped in
PATCH/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.

⛓ 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.
GET/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).

Bearer token
GET https://api.kerja.team/v1/me
↩︎Response 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.idUUIDalways
The authenticated user's unique identifier. Use to reference this identity in subsequent calls.
data.emailstringalways
The user's current email address (NFC-normalized). Note it may be unverified; check emailVerifiedAt.
data.displayNamestringalways
The user's human-readable display name. Render in profile/UI.
data.hasMfaEnabledbooleanalways
Whether the user has multi-factor authentication enabled. Drives whether to surface MFA management options.
  • trueMFA is enabled for this user.
  • falseMFA is not enabled for this user.
data.emailVerifiedAtISO-8601always
When the email was verified, or null if not yet. Note an email CHANGE no longer resets this: the new address is staged as pending_email and this stays stamped until the new one is confirmed and swapped in.
data.phonestringalways
The user's E.164 phone, or null on an email-only account. Check phoneVerifiedAt before treating it as a usable channel.
data.phoneVerifiedAtISO-8601always
When the phone was verified, or null. A verified phone is the prerequisite for phone-OTP MFA (scenario 25) and for selecting loginMode: phone (scenario 31).
data.loginModeenumalways
The account's PRIMARY channel — genuinely new information that cannot be derived, because an account holding both credentials is otherwise ambiguous at exactly the point the login screen must be chosen. Set at registration (phone sign-up and phone invitations -> phone; email sign-up and email invitations -> email) and changed only with step-up.
  • emailSigns in at scenario 9 (email + password); a new-device challenge arrives as an email code.
  • phoneSigns 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.
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/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.

Request body (JSON)
ℹ︎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 choose
The address awaiting confirmation. The lookup spans BOTH a not-yet-verified active email (first-time sign-up) and a staged pending_email from an email change, so a resend to the pending address works even while the current active email stays verified. Something to send → 202; nothing to send (unknown email OR an already-verified address that is not a staged pending one) → 409, with an OR-list message that does not disclose which applied. Valid email, at most 320 chars.
POST https://api.kerja.team/v1/auth/email/verify/resend
↩︎Response 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
Always "accepted". A fresh challenge was minted and delivered out-of-band — BOTH halves together (a new 24h link and a new 15 min code), so the newest message is internally consistent and any earlier code or link is dead.