switching clears the token & responses
No token
36

Reviewing and cancelling pending credential changes

Profile & credentials

Email, 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.

  1. 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
  2. GET /v1/me/credential-changes?limit= → the history, newest first, every status
  3. Signed in: DELETE /v1/me/credential-changes → cancels every live change — email, phone and password alike
  4. 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 }
GET/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.

Bearer token
→ GET https://api.kerja.team/v1/me
↩︎Response 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.idUUIDalways
The authenticated user's unique identifier. Use to reference this identity in subsequent calls.
data.emailstring | nullalways
The email that SIGNS IN now (NFC-normalized), or null on a phone-only account. An in-flight email change never alters it — the new address sits in pendingEmail until it applies. May be unverified; check emailVerifiedAt.
data.pendingEmailstring | nullalways
The new address of a live email change (awaiting its code, or held until effectiveAt), or null. It never signs in while pending (scenario 30).
data.pendingPhonestring | nullalways
The new number of a live phone change, or null (scenario 31). Never signs in while pending.
data.pendingChangesCredentialChange[]always
Every LIVE change row (awaiting_verification or held), so a client can offer resend and cancel without a second call; [] when nothing is in flight. Full history is GET /v1/me/credential-changes (scenario 36).
data.pendingChanges[].idUUIDalways
The change row's id. Not needed to act on it — verify / resend / cancel address the caller's ONE live change of that kind (/v1/me/email-change, /v1/me/phone-change, /v1/me/password-change), never an id.
data.pendingChanges[].kindenumalways
Which credential the change replaces. At most one live change per kind; email beside phone only when both are strong, and a password change never sits beside a contact change.
  • email — 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 | nullalways
The target address / number this change would install. null for the password kind (never echoed) and for a closed contact change. Until the row is applied it does NOT sign in — the current value on the profile still does.
data.pendingChanges[].statusenumalways
Where the change stands. Only the two LIVE statuses appear in pendingChanges[] — closed rows are in GET /v1/me/credential-changes.
  • awaiting_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[].assuranceenumalways
The assurance of the step-up token the request spent — it decides what verify does.
  • password — 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-8601always
When the change was requested.
data.pendingChanges[].verifiedAtISO-8601 | nullalways
When the code was confirmed; null while awaiting_verification and on a change that never got that far.
data.pendingChanges[].effectiveAtISO-8601 | nullalways
When a HELD change applies (verifiedAt + CONTACT_CHANGE_HOLD_SECONDS) — show it as "takes effect on …". null when there is no hold.
data.pendingChanges[].expiresAtISO-8601always
When an unconfirmed change lapses to expired (requestedAt + CONTACT_CHANGE_PENDING_TTL_SECONDS, 24 h).
data.pendingChanges[].codeExpiresAtISO-8601 | nullalways
When the CURRENT code dies (email 15 min, phone 5 min). Past it, …/resend — the change itself is still live until expiresAt.
data.pendingChanges[].codeSentTostring | nullalways
Where the code went, masked (b•••@new.id, +62 812••••21) — the new value for a contact change, the loginMode channel for a password change.
data.displayNamestringalways
The user's human-readable display name. Changed only through PUT /v1/me/display-name (scenario 28).
data.hasMfaEnabledbooleanalways
Whether the user has multi-factor authentication enabled. Drives whether to surface MFA management options.
  • true — 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.hasPasswordbooleanalways
Whether a password is set — the boolean only, never the hash. Every profile change starts by proving the current password at POST /v1/me/step-up.
  • true — 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 | nullalways
When the current email was verified, or null. A pending email change does not reset it; it is re-stamped only when the change applies.
data.phonestring | nullalways
The E.164 phone that signs in now, or null on an email-only account. A pending phone change never alters it. Check phoneVerifiedAt before treating it as a usable channel.
data.phoneVerifiedAtISO-8601 | nullalways
When the phone was verified, or null. A verified phone is the prerequisite for phone-OTP MFA (scenario 25) and for loginMode: phone (scenario 33).
data.loginModeenumalways
The account's PRIMARY channel — set at registration (phone sign-up / phone invitation → phone; email → email) and changed only by PUT /v1/me/login-mode on a strong step-up (scenario 33). It is also where a password-change code goes.
  • email — 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
The LIVE, active organization this identity founded (tenants.created_by = you), or null — the same predicate POST /v1/me/organizations enforces. null → offer "found your organization" (scenario 24); non-null → that route can only 409 organization_already_owned, so offer a way into this tenant instead. Founding is not membership: an admin seat elsewhere never sets it, and a founder who left their own organization still sees it.
GET/v1/me/credential-changesauthChange history

CredentialChange[] 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.

Query parameters
ℹ︎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 choose
How many rows to return, integer 1–100, default 20. There is no cursor — this is the most recent N.
Bearer token
→ GET https://api.kerja.team/v1/me/credential-changes?limit=20
↩︎Response 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[]always
One row per change ever requested, newest first; [] when there are none.
data[].idUUIDalways
The change row's id. Not needed to act on it — verify / resend / cancel address the caller's ONE live change of that kind (/v1/me/email-change, /v1/me/phone-change, /v1/me/password-change), never an id.
data[].kindenumalways
Which credential the change replaces. At most one live change per kind; email beside phone only when both are strong, and a password change never sits beside a contact change.
  • email — 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 | nullalways
The target address / number. Kept on applied rows; null on closed (cancelled / expired / failed) contact changes and ALWAYS null for the password kind — the new password is only ever held as a hash and cleared once applied.
data[].statusenumalways
Where the change stands — the field that decides the client's next step.
  • awaiting_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[].assuranceenumalways
The assurance of the step-up token the request spent — it decides what verify does.
  • password — 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-8601always
When the change was requested.
data[].verifiedAtISO-8601 | nullalways
When the code was confirmed; null while awaiting_verification and on a change that never got that far.
data[].effectiveAtISO-8601 | nullalways
When a HELD change applies (verifiedAt + CONTACT_CHANGE_HOLD_SECONDS) — show it as "takes effect on …". null when there is no hold.
data[].expiresAtISO-8601always
When an unconfirmed change lapses to expired (requestedAt + CONTACT_CHANGE_PENDING_TTL_SECONDS, 24 h).
data[].codeExpiresAtISO-8601 | nullalways
When the CURRENT code dies (email 15 min, phone 5 min). Past it, …/resend — the change itself is still live until expiresAt.
data[].codeSentTostring | nullalways
Where the code went, masked (b•••@new.id, +62 812••••21) — the new value for a contact change, the loginMode channel for a password change.
DELETE/v1/me/credential-changesauthSigned in — cancel every live change

204, 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.

Bearer token
→ DELETE https://api.kerja.team/v1/me/credential-changes
↩︎Response 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 [].

POST/v1/auth/credential-change/cancelpublicSigned out — cancel from the notice link

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

Request body (JSON)
ℹ︎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-band
The cancel token from the link in a contact_change_requested / contact_change_held notice (the link points at CREDENTIAL_CHANGE_CANCEL_URL, the page that submits it). THERE IS NO OOB HELPER FOR IT — it exists only in the notice, so take it from the delivered message (or the notifier's log). Lives CREDENTIAL_CHANGE_CANCEL_TOKEN_TTL_SECONDS (default 4 days = pending + hold). Unknown, used or expired token, or an account with nothing live — all the same 401 invalid_token. The body is .strict().
→ POST https://api.kerja.team/v1/auth/credential-change/cancel
↩︎Response 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.kindenumalways
Which change the link cancelled. Only contact changes carry a cancel link.
  • email — 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.statusenumalways
The row's new status.
  • cancelled — The only value on a 200 — the change will never apply. The user may start a new one after a fresh step-up.