Reviewing and cancelling pending credential changes
Profile & credentialsEmail, phone and password changes (scenarios 30, 31, 35) each leave a change row that lives until it is confirmed, applied, cancelled or expires. This is the view across all of them — what is in flight, what happened before, and how to stop it, including from a notice when you are NOT signed in.
- GET /v1/me → pendingChanges lists every LIVE change (status, masked code destination, code and change expiry, when a held change applies); pendingEmail / pendingPhone name the new values
- GET /v1/me/credential-changes?limit= → the history, newest first, every status
- Signed in: DELETE /v1/me/credential-changes → cancels every live change — email, phone and password alike
- Signed out: POST /v1/auth/credential-change/cancel { token } from the link in a contact_change_requested / contact_change_held notice → 200 { kind, status: cancelled }
/v1/meauthWhat is in flight? (pendingChanges)pendingChanges carries every LIVE change row in the CredentialChange shape — status (awaiting_verification or held), codeSentTo (masked), codeExpiresAt, expiresAt, and effectiveAt for a held change — so a client can offer resend and cancel without a second call; pendingEmail / pendingPhone name the new values. These are your OWN values on a /me route, so they disclose nothing you did not just supply.
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/credential-changesauthChange historyCredentialChange[] newest first, EVERY status — awaiting_verification, held, applied, cancelled, expired, failed — not only live rows. value is kept on applied rows and null on closed ones; the password kind never carries a value.
Field guide — what each value means & where it comes from1 field
The caller's change history (E-19), newest first, as CredentialChange[] — EVERY status (awaiting_verification, held, applied, cancelled, expired, failed), not only live rows. value is kept on applied rows and null on closed ones; the password kind never carries a value. Auth + no-store. The LIVE subset is also on GET /v1/me as pendingChanges. Walk-through: scenario 36.
limitqueryintegeroptionalyou chooseGET https://api.kerja.team/v1/me/credential-changes?limit=20Response guide — what comes back & what each value means12 fields
200 OK, { data } envelope, Cache-Control: no-store — the caller's change HISTORY as CredentialChange[], newest first, EVERY status (not only live rows; the live ones are also on GET /v1/me as pendingChanges[]). limit 1–100, default 20. value is kept on applied rows and null on closed ones; the password kind never carries a value.
dataCredentialChange[]alwaysdata[].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/credential-changesauthSigned in — cancel every live change204, idempotent, NO step-up. Cancels every live change — email, phone and password alike. Contact changes send contact_change_cancelled and their notice cancel link is burnt. To cancel one kind only: DELETE /v1/me/email-change (scenario 30), DELETE /v1/me/phone-change (scenario 31), DELETE /v1/me/password-change (scenario 35). A LIVE CONTACT CHANGE BLOCKS THE RISKY CHANGES — switching the login channel, changing the password and removing the phone answer 409 change_in_progress until it is confirmed or cancelled. A password reset (scenario 34) also cancels every live change.
Field guide — what each value means & where it comes fromno inputs
No body. Cancels EVERY live change of the caller (E-16) — email, phone and password alike, awaiting their code or held. 204, idempotent, NO step-up. Contact changes send contact_change_cancelled and the notice cancel link is burnt. To cancel one kind only, use DELETE /v1/me/email-change, /phone-change or /password-change. Walk-through: scenario 36.
DELETE https://api.kerja.team/v1/me/credential-changesResponse guide — what comes back & what each value meansno body
204 No Content — no body. Cancels EVERY live change of the caller — email, phone and password alike (scenario 36); idempotent, no step-up. Contact changes send contact_change_cancelled and burn their cancel link; the password change is dropped silently. Afterwards GET /v1/me shows pendingEmail / pendingPhone null and pendingChanges [].
/v1/auth/credential-change/cancelpublicSigned out — cancel from the notice linkPublic and SESSIONLESS: the single-use token carried by the contact_change_requested and contact_change_held notices is the proof. It resolves the account (at most one password-assurance contact change is live per account), cancels the change (by notice_link), sends contact_change_cancelled and burns the token → 200 { kind, status: cancelled }. The way to stop a password-only change someone started with a STOLEN SESSION. THE LINK EXISTS ONLY FOR A PASSWORD-ASSURANCE CONTACT CHANGE — a strong one applies at once and there is nothing to cancel. The token lives CREDENTIAL_CHANGE_CANCEL_TOKEN_TTL_SECONDS (default 4 days = pending + hold). Every failure — unknown, used or expired token, or nothing live — is the same 401 invalid_token, throttled at 10 per IP per 15 minutes (429). There is no out-of-band helper for this token: it exists only in the notice.
Field guide — what each value means & where it comes from1 field
Cancels a PASSWORD-ASSURANCE contact change from the notice link (E-17) — the way to stop a change someone started with a stolen session. PUBLIC AND SESSIONLESS: no bearer, no gateway context; the single-use token carried by the contact_change_requested and contact_change_held notices is the proof. The token resolves the account (at most one password-assurance contact change is live per account); the change is cancelled by notice_link, contact_change_cancelled is sent, and the token is burnt. 200 { kind, status: cancelled }. Throttled 10 per IP per 15 minutes → 429; no-store. The link exists only for a password-assurance change — a strong one applies at once and there is nothing to cancel. Walk-through: scenario 36.
tokenbodystringrequiredout-of-bandPOST https://api.kerja.team/v1/auth/credential-change/cancelResponse guide — what comes back & what each value means2 fields
200 OK, { data } envelope, Cache-Control: no-store. PUBLIC and sessionless (scenario 36): the single-use token from the contact_change_requested / contact_change_held notice link is the proof. It resolves the account's one live password-assurance contact change, cancels it (cancelled_by notice_link), sends contact_change_cancelled, and burns the token. Unknown, used or expired token, or nothing live, are all the same 401 invalid_token; 429 at 10 per IP per 15 min. The token lives CREDENTIAL_CHANGE_CANCEL_TOKEN_TTL_SECONDS (4 days = pending + hold).
data.kindenumalwaysemail— The pending / held email change is cancelled; the current address stays.phone— The pending / held phone change is cancelled; the current number (if any) stays.
data.statusenumalwayscancelled— The only value on a 200 — the change will never apply. The user may start a new one after a fresh step-up.