Logging in with a phone (one-time code)
Signing inAn existing phone user signs in with an OTP and NO password at all — primary phone authentication, distinct from the phone-OTP second factor (scenario 12) and from phone + password (scenario 10). Deliberately untouched by Enhancement 4 AS AN AUTHENTICATION FLOW: no device challenge, and clearing it earns no device trust; amr stays [sms]. The one exception is about ADDRESSING, not authentication — a device_token cookie may supply the destination so the number can be omitted.
- POST /v1/auth/phone/otp/request (purpose=login) → 202 + an OTP if the phone is registered; 404 phone_not_registered if unknown
- POST /v1/auth/phone/otp/verify → accessToken + refresh cookie (amr: [sms])
/v1/auth/phone/otp/requestpublicRequest login OTPpurpose=login. 202 + an OTP if the number is registered; 404 phone_not_registered if unknown (deliberate reveal). Not gated by the REGISTRATION policy — that one governs sign-up only — but it IS gated by the LOGIN policy (scenario 7): with phone absent from allowedMethods BOTH halves of this pair answer 403 login_method_disabled, including a code that was already sent, so the restriction does not lapse as outstanding codes drain. phone is OPTIONAL here when a device_token cookie is presented: the destination is taken from the account's own verified number, matching how POST /v1/auth/login asks a recognised browser for nothing but a password. The cookie is read ONLY to decide where to send the code — it grants no trust, skips no step, and the OTP must still be proven. Nothing resolvable → the same 404, not a new oracle.
Field guide — what each value means & where it comes from2 fields
Public endpoint (no Authorization needed) — begins a phone OTP challenge. It DELIBERATELY REVEALS the number's real state before minting a code: purpose=register on a taken number → 409 phone_already_registered, purpose=login on an unknown number → 404 phone_not_registered, otherwise 202. “Taken” means a FINISHED registration, not merely that a row exists — an identity left half-built by an abandoned phone sign-up (verified phone, no password, no membership) is RESUMED with a fresh OTP, because answering 409 “log in instead” would strand someone who has no password to log in with. An identity that has a password, or any membership row at all (an invitation's shell identity), is not resumable and still answers 409. The notifier sends the SMS out-of-band; retrieve the code with the ⛓ helper for the follow-up call.
phonebodyE.164conditionalyou choosepurposebodyenumoptionalyou chooseregister— Default. Begins a phone sign-up challenge. Gated by the REGISTRATION policy → 403 registration_method_disabled when phone is not allowed. Stays open even under combinationMode=atomic, because the combined sign-up body needs this OTP.login— Begins a login challenge for an existing phone account. Gated by the LOGIN policy → 403 login_method_disabled when phone is not in its allowedMethods, which also closes the paired phone/otp/verify and kills codes already sent.
POST https://api.kerja.team/v1/auth/phone/otp/requestResponse guide — what comes back & what each value means2 fields
202 Accepted with the standard { data } envelope; body is the uniform { data: { status: "accepted" } } returned regardless of whether the phone is already registered (anti-enumeration). No Set-Cookie or special Cache-Control headers — this endpoint only begins a phone challenge (emits an otp.requested outbox event); no session is issued.
dataobjectalwaysdata.statusenumalwaysaccepted— The OTP request was accepted; an otp.requested outbox event was emitted for an external notifier to send the SMS. This is the only value ever returned on success.
/v1/auth/phone/otp/verifypublicVerify login OTPphone is OPTIONAL on a recognised device, exactly as in the request half — leaving one required and the other not would let the user type a valid code and watch sign-in fail. Verifies the OTP for an existing phone user → data.accessToken (auto-captured), amr stays [sms] — which is how a consumer distinguishes this from the password paths. Gated by the LOGIN policy (scenario 7): 403 login_method_disabled when phone is not in allowedMethods, even for a code this endpoint's own request half already delivered. Same tenant resolution as password login: the DEFAULT membership is chosen (use switch-tenant, scenario 20). 403 tenant_unavailable if the resolved tenant isn't active. Bad/expired codes are throttled and uniform — a client cannot tell “wrong code” from “unknown phone”.
Field guide — what each value means & where it comes from2 fields
Public phone-login endpoint; no Authorization needed. Body is .strict(). On success it sets a refresh_token httpOnly cookie and returns an access token (amr [sms]). Gated by the LOGIN policy (scenario 7) → 403 login_method_disabled when phone is not in allowedMethods, even for a code its own request half already delivered. Two failure codes, split around the OTP: a 401 OR-list BEFORE the code is proven (wrong / expired / consumed, or an unverified-or-suspended account — never revealing which), and 404 phone_not_registered AFTER it (possession proven but no account exists → sign up first), which is not an enumeration leak because it is unreachable without a valid OTP.
phonebodyE.164conditionalyou choosecodebodystringrequiredout-of-bandPOST https://api.kerja.team/v1/auth/phone/otp/verifyResponse guide — what comes back & what each value means7 fields
200 OK returning the standard { data } envelope; response is non-cacheable (Cache-Control: no-store) and sets the rotating refresh_token as an httpOnly cookie via Set-Cookie. Body carries the issued session: an access token plus a minimal user profile. Single response shape (unlike password login, there is no MFA branch here).
data.accessTokenJWTalwaysdata.tokenTypeenumalwaysBearer— Send the access token as `Authorization: Bearer <accessToken>`.
data.expiresInintegeralwaysdata.userobjectalwaysdata.user.idUUIDalwaysdata.user.phonestringalwaysdata.user.tenantIdUUIDalways