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 a LIVE membership (active or suspended), is not resumable and still answers 409 — so this can neither re-register a finished account nor launder a suspension. An INVITED seat does not count against it: an R3 phone invitation's shell identity (number set, unverified, no password, one invited seat) IS resumable and register answers 202 — registering creates a NEW tenant and the invited seat survives, listed as invited at GET /v1/me/memberships so it can still be accepted. 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 when the code was issued. NOT anti-enumeration — it reveals the real state: purpose register on a number that already has a completed account → 409 phone_already_registered; purpose login on an unknown number → 404 phone_not_registered. (An abandoned, never-completed registration or a pending invitation answers 202 and re-issues the OTP.) Stores a keyed HMAC of the code and emits a notification.otp_requested outbox event for the external notifier; no session, no Set-Cookie. Policy-gated for purpose register.
dataobjectalwaysdata.statusenumalwaysaccepted— The only value on a 202: a notification.otp_requested outbox event was emitted for the external notifier to send the WhatsApp/SMS code.
/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 21). 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 means14 fields
200 OK, { data } envelope, Cache-Control: no-store. Normally the phone-login session: an access token (amr [sms]) plus a minimal user, with the rotating refresh_token as an httpOnly Set-Cookie. Tenant resolution is the same as password login (0 active memberships deny, 1 is taken, many → default). It can also end in one of the two SESSIONLESS 200s of §3.6 when the chain clears for an account that belongs nowhere — { noOrganization, bootstrapToken, expiresIn } (scenario 15) or { invitationPending, acceptToken, expiresIn, invitations[] } (scenario 52): neither mints a session row, a refresh or device cookie, an access token or a tenant_id claim. READ the body; a 200 alone is not "signed in".
data.accessTokenstring (JWT)conditionaldata.tokenTypeenumconditionalBearer— Send the access token as Authorization: Bearer <accessToken>.
data.expiresInnumberconditionaldata.user.idUUIDconditionaldata.user.phonestringconditionaldata.user.tenantIdUUIDconditionalSet-Cookie: refresh_tokencookieconditionaldata.noOrganizationbooleanconditionaltrue— Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditionaldata.invitationPendingbooleanconditionaltrue— Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditionaldata.invitationsarrayconditionaldata.invitations[].tenantIdUUIDconditionaldata.invitations[].tenantNamestringconditional