Adding an email credential
Profile & credentialsA signed-in user who has NO email links one — typically a phone-first identity from scenario 3. It is the SAME route as an email change (scenario 30), and the differences all follow from there being no old address to protect. Routed here by email: null on GET /v1/me.
- GET /v1/me → email: null is what routes you here; hasPassword decides whether the step-up can start at all
- POST /v1/me/step-up { action: email_change, currentPassword } → a step-up token, plus the factors on offer: the verified phone as the other channel, a TOTP or a recovery code. There is NO “current value” factor — that is the thing being added
- Optional: prove one factor (…/code, then …/verify) → a STRONG token, which applies the change the moment its code is confirmed instead of holding it three days
- POST /v1/me/email-change { email, stepUpToken } → 202; a code goes to the NEW address
- Optional: POST /v1/me/email-change/resend → a fresh code · DELETE /v1/me/email-change → 204 to cancel
- POST /v1/me/email-change/verify { code } → strong: the address is set AND verified at once · password: held three days, then applied by worker job J7
/v1/meauthDo I have an email yet? (email: null routes you here)email: null is what routes you to this scenario rather than to a change (scenario 30). hasPassword is the other field to read: A PASSWORD IS REQUIRED AND THERE IS NO IN-APP WAY TO SET A FIRST ONE — both the step-up and the request answer 409 no_password. A phone-first account created by the DEPRECATED one-shot sign-up has none, and gets one through the reset flow on its verified phone (scenario 34) before coming back here.
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, phone, displayName, hasMfaEnabled, emailVerifiedAt, phoneVerifiedAt, loginMode — plus the fields the §153 / §136 flows read first: hasPassword (false → every profile change is refused 409 no_password at POST /v1/me/step-up; set one through the reset flow, scenario 34), pendingEmail / pendingPhone and pendingChanges[] (the live credential changes in CredentialChange shape — email and phone keep naming the values that still sign in, scenario 36), and foundedTenantId (null → founding is offered; set → POST /v1/me/organizations would only 409, scenario 24). 404 if the user record is gone.
GET https://api.kerja.team/v1/meResponse guide — what comes back & what each value means24 fields
200 OK. Body is the standard { data } envelope wrapping the current user's profile; no Set-Cookie. Internal columns such as the password hash are never returned. Since §153 the profile also carries the IN-FLIGHT credential changes (pendingEmail, pendingPhone, pendingChanges[]) — email / phone keep naming the values that still sign in — plus hasPassword (can a step-up start at all?) and foundedTenantId (should the client offer founding an organization?). PUT /v1/me/display-name and PUT /v1/me/login-mode answer with this same shape.
data.idUUIDalwaysdata.emailstring | nullalwaysdata.pendingEmailstring | nullalwaysdata.pendingPhonestring | nullalwaysdata.pendingChangesCredentialChange[]alwaysdata.pendingChanges[].idUUIDalwaysdata.pendingChanges[].kindenumalwaysemail— 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 | nullalwaysdata.pendingChanges[].statusenumalwaysawaiting_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[].assuranceenumalwayspassword— 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-8601alwaysdata.pendingChanges[].verifiedAtISO-8601 | nullalwaysdata.pendingChanges[].effectiveAtISO-8601 | nullalwaysdata.pendingChanges[].expiresAtISO-8601alwaysdata.pendingChanges[].codeExpiresAtISO-8601 | nullalwaysdata.pendingChanges[].codeSentTostring | nullalwaysdata.displayNamestringalwaysdata.hasMfaEnabledbooleanalwaystrue— 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.hasPasswordbooleanalwaystrue— 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 | nullalwaysdata.phonestring | nullalwaysdata.phoneVerifiedAtISO-8601 | nullalwaysdata.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 DELETE /v1/me/phone answers 409 last_credential until this is switched back to email.
data.foundedTenantIdUUID | nullalways/v1/me/step-upauthStart a step-up for email_changeProves 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). The factors on offer are the verified PHONE (the other channel), a TOTP or a recovery code — there is no current-value factor, because the email is the thing being added. A PHONE THAT IS ITSELF RECENT CANNOT VOUCH FOR THE EMAIL: a channel whose current value was applied with password-only assurance inside CHANNEL_TRUST_AGE_SECONDS (default 30 days) is “young” and is not offered, because a channel a password alone could have set must not then authorise the next change. For an ordinary phone-first account this does NOT bite — a phone verified during registration carries no such record — but an account that added or replaced its phone on a password-only token in the last 30 days, with no TOTP and no recovery codes, has no eligible factor at all and its first email is held the full three days.
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 choosedisplay_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 choosePOST https://api.kerja.team/v1/me/step-upResponse 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.challengeIdUUIDalwaysdata.stepUpTokenstring (43-char opaque)alwaysdata.assuranceenumalwayspassword— 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.expiresInnumberalwaysdata.passwordOnlyAllowedbooleanalwaystrue— 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.optionsarrayalwaysdata.options[].factorenumalwaysemail_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[].roleenumconditionalcurrent_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[].maskedstringconditionaldata.loginChannelobjectconditionaldata.loginChannel.channelenumconditionalemail— The password-change code will be emailed.phone— The password-change code will go to the phone by WhatsApp/SMS.
data.loginChannel.maskedstringconditional/v1/me/step-up/{challengeId}/codeauthSend a step-up code to one of the offered channelsOnly 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.
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-capturedfactorbodyenumrequiredfrom a responseemail_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).
POST https://api.kerja.team/v1/me/step-up/{challengeId}/codeResponse 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.statusenumalwaysaccepted— 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.maskedstringalwaysdata.ttlSecondsnumberalways/v1/me/step-up/{challengeId}/verifyauthProve the factor → a STRONG tokenfactor 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.
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-capturedfactorbodyenumrequiredfrom a responseemail_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-bandPOST https://api.kerja.team/v1/me/step-up/{challengeId}/verifyResponse 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)alwaysdata.assuranceenumalwaysstrong— Always this value here: password plus a second factor. Satisfies every step-up-gated action it was minted for.
data.expiresInnumberalways/v1/me/email-changeauthRequest an email change (code to the NEW address)email is trimmed, ≤ 320 chars, NFC-normalized; stepUpToken is an email_change token — password or strong. 202 with the CredentialChange row (id, kind, value, status awaiting_verification, assurance, requestedAt / verifiedAt / effectiveAt / expiresAt / codeExpiresAt, codeSentTo). The code is the contact_change.email message (15 min). A PASSWORD-assurance request also sends contact_change_requested to every verified current channel, carrying a single-use cancel link; a strong one sends nothing at request. Unverified, the change expires after CONTACT_CHANGE_PENDING_TTL_SECONDS (default 24 h). 409 conflict when the address belongs to another live account (re-checked at verify, and again by J7 during a hold) or an Idempotency-Key is reused with another body; 409 change_in_progress (one live change per kind; email beside phone only when BOTH are strong; never beside a password change); 409 no_password; 429 over CREDENTIAL_CHANGE_MAX_PER_DAY (default 5). 422 unchanged CANNOT fire here: it means “already your verified current address”, which an account with no address does not have. The request notice goes to the verified PHONE, since there is no old address to tell, and carries the sessionless cancel link (scenario 36).
Field guide — what each value means & where it comes from3 fields
Requests an email change (E-5). 202 with the CredentialChange row { id, kind: email, value, status: awaiting_verification, assurance, requestedAt, expiresAt, codeExpiresAt, codeSentTo, … }. A code (contact_change.email, 15 min) goes to the NEW address; until the change applies the CURRENT address keeps signing in and a pending value never does — read it back as pendingEmail / pendingChanges at GET /v1/me. Auth + throttled + no-store; .strict(). ASSURANCE DECIDES WHAT FOLLOWS: a PASSWORD-assurance request also sends contact_change_requested to every verified current channel carrying a single-use sessionless CANCEL LINK (scenario 36), and its verify is HELD for 3 days; a STRONG request sends nothing now and applies at verify. An add over a never-verified current email burns that address's registration-era verify tokens. Expires unverified after CONTACT_CHANGE_PENDING_TTL_SECONDS (default 24 h). Errors: 401 step_up_required · 409 no_password · 409 change_in_progress (one live change per kind; email beside phone only when BOTH are strong; never beside a password change) · 409 conflict (the address belongs to another account, or an Idempotency-Key reused with another body) · 422 unchanged (already your verified address) · 429 over CREDENTIAL_CHANGE_MAX_PER_DAY (default 5 in 24 h). Walk-through: scenario 30.
Idempotency-KeyheaderUUIDoptionalclient-generatedemailbodystringrequiredyou choosestepUpTokenbodystringrequiredauto-capturedPOST https://api.kerja.team/v1/me/email-changeResponse guide — what comes back & what each value means11 fields
202 Accepted, { data } envelope, Cache-Control: no-store — the CredentialChange row with status awaiting_verification (scenario 30). A code (contact_change.email, 15 min) went to the NEW address; until the change applies the CURRENT address keeps signing in and the pending one never does. Spends an email_change step-up token — password assurance or strong, and which one decides what /verify does (held vs applied). On PASSWORD assurance every verified current channel is also sent contact_change_requested carrying a single-use cancel link (POST /v1/auth/credential-change/cancel); a strong request sends nothing else. With an Idempotency-Key a retry with the same body replays this stored 202. Refusals: 409 change_in_progress (details[0].blocking lists { kind, status }), 409 conflict (address belongs to another account, or key reused with another body), 422 unchanged, 429 over CREDENTIAL_CHANGE_MAX_PER_DAY (5 / 24 h).
data.idUUIDalwaysdata.kindenumalwaysemail— 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 | nullalwaysdata.statusenumalwaysawaiting_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.assuranceenumalwayspassword— 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-8601alwaysdata.verifiedAtISO-8601 | nullalwaysdata.effectiveAtISO-8601 | nullalwaysdata.expiresAtISO-8601alwaysdata.codeExpiresAtISO-8601 | nullalwaysdata.codeSentTostring | nullalways/v1/me/email-change/resendauthOptional — resend the code to the new address202 { ttlSeconds } — a fresh code to the new address; the previous code dies. 409 no_pending_change; 429 at 5 per 15 min per change and per destination.
Field guide — what each value means & where it comes fromno inputs
No body. Sends a fresh code to the NEW address (E-7); the previous code dies. 202 { ttlSeconds: 900 }. Auth + throttled (5 per 15 min per change and per destination → 429) + no-store. 409 no_pending_change when no email change is awaiting its code — a HELD change has already been verified and has no code to resend. The registration resend (POST /v1/auth/email/verify/resend) does not reach a change address.
POST https://api.kerja.team/v1/me/email-change/resendResponse guide — what comes back & what each value means1 field
202 Accepted, { data } envelope, Cache-Control: no-store. A fresh code went to the NEW address and the previous code is dead; the change row itself is unchanged. 409 no_pending_change when no email change is awaiting its code (a held change has nothing to resend); 429 at 5 per 15 min per change and per destination.
data.ttlSecondsnumberalways/v1/me/email-changeauthOptional — changed your mind204, idempotent, NO step-up — cancelling only ever keeps the current value. Works while awaiting the code or held. Every verified current channel receives contact_change_cancelled and the cancel link is burnt. Every live change at once is DELETE /v1/me/credential-changes (scenario 36).
Field guide — what each value means & where it comes fromno inputs
No body. Cancels the live email change (E-8) — awaiting its code OR held. 204, whether something was cancelled or nothing was live (idempotent). NO step-up: cancelling only ever keeps the current value, so it needs no proof. Every verified current channel receives contact_change_cancelled and the notice's cancel link is burnt. Audited credential_change_cancelled (cancelled_by = user). Signed out, the same change is cancelled from the notice link instead (POST /v1/auth/credential-change/cancel, scenario 36).
DELETE https://api.kerja.team/v1/me/email-changeResponse guide — what comes back & what each value meansno body
204 No Content — no body. Cancels the live email change, whether awaiting its code or held; idempotent (204 also when nothing was live) and needs no step-up, since cancelling only ever keeps the current value. Every verified current channel receives contact_change_cancelled and the notice cancel link is burnt. Audits credential_change_cancelled (cancelled_by = user). The row reads cancelled in GET /v1/me/credential-changes.
/v1/me/email-change/verifyauthConfirm the code — apply now (strong) or hold (password)The code sent to the NEW address (4–8 digits). STRONG → applied now: email becomes the new address, verified; user.upserted is projected once per app across your tenants. PASSWORD → HELD for CONTACT_CHANGE_HOLD_SECONDS (default 3 days): every verified current channel receives contact_change_held naming the effective time and carrying 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 address was taken meanwhile (the change is failed and contact_change_failed is sent). If the address was a typo this step never happens and the change expires after 24 h. Never mints a session or device trust. NO SEPARATE VERIFICATION STEP: the code sent to the new address IS the verification, so on apply email and emailVerifiedAt are set together — POST /v1/auth/email/verify is registration-only and plays no part here. IT DOES NOT CHANGE HOW YOU SIGN IN: loginMode stays phone until it is changed deliberately (scenario 33), and the new address unlocks email_code as a step-up and new-device factor only once it is verified and no longer young.
Field guide — what each value means & where it comes from1 field
Confirms the code sent to the NEW address (E-6). 200 with the CredentialChange row. What happens next depends on the assurance the request spent: STRONG → status applied — email becomes the new address, verified; user.upserted is projected once per app across the user's tenants; the OLD address and the other verified channel receive contact_change_applied. PASSWORD → status held for CONTACT_CHANGE_HOLD_SECONDS (default 3 days) — the current address keeps signing in, every verified current channel receives contact_change_held naming effectiveAt and carrying the SAME cancel link as the request notice, and worker job J7 applies it at effectiveAt. Never mints a session and never grants device trust. Auth + no-store; .strict(). This — not POST /v1/auth/email/verify — is where an email change is confirmed. Errors: 401 invalid_code (wrong, expired or used; the 5th wrong code burns it — resend restores) · 409 no_pending_change · 409 conflict (the address was taken meanwhile — the change is failed and contact_change_failed is sent).
codebodystringrequiredout-of-bandPOST https://api.kerja.team/v1/me/email-change/verifyResponse guide — what comes back & what each value means11 fields
200 OK, { data } envelope, Cache-Control: no-store — the CredentialChange row, now applied or held depending on the assurance the REQUEST spent. Never mints a session or grants device trust. STRONG → applied now: email becomes the new address, verified; user.upserted projected once per app across the user's tenants; the old address and the other verified channel get contact_change_applied. PASSWORD → held for CONTACT_CHANGE_HOLD_SECONDS (3 days): the current address keeps signing in, every verified current channel gets contact_change_held naming effectiveAt with the same cancel link, and worker J7 applies it then. 401 invalid_code (the 5th wrong code burns it; resend restores); 409 no_pending_change; 409 conflict = the address was taken meanwhile, the row is now failed.
data.idUUIDalwaysdata.kindenumalwaysemail— 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 | nullalwaysdata.statusenumalwaysawaiting_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.assuranceenumalwayspassword— 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-8601alwaysdata.verifiedAtISO-8601 | nullalwaysdata.effectiveAtISO-8601 | nullalwaysdata.expiresAtISO-8601alwaysdata.codeExpiresAtISO-8601 | nullalwaysdata.codeSentTostring | nullalways