switching clears the token & responses
No token
14

Logging in with a phone (one-time code)

Signing in

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

  1. POST /v1/auth/phone/otp/request (purpose=login) → 202 + an OTP if the phone is registered; 404 phone_not_registered if unknown
  2. POST /v1/auth/phone/otp/verify → accessToken + refresh cookie (amr: [sms])
POST/v1/auth/phone/otp/requestpublicRequest login OTP

purpose=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.

Request body (JSON)
ℹ︎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 choose
The destination phone number, E.164 matching ^\+[1-9][0-9]{6,14}$ (e.g. +15551234567). ALWAYS required for purpose=register — signing up creates an account that does not exist yet, so no device could name it. OPTIONAL for purpose=login when a valid device_token cookie is presented: the destination is then taken from the account's own verified number. That mirrors POST /v1/auth/login, which also asks a recognised browser for nothing but a password; without it, switching factors on one browser would be incoherent — the password path asking for nothing while the code path demanded a number the UI only ever shows masked. When nothing can be resolved (no cookie, or one that is unknown / expired / revoked / on an account with no verified phone) the answer is the SAME 404 phone_not_registered an unregistered number gets, so no second oracle is added. The number is never echoed back.
purposebodyenumoptionalyou choose
You pick whether this challenge is for a new sign-up or an existing-account login. Defaults to register. The two are gated by DIFFERENT policies — register by the registration policy (scenario 1), login by the login policy (scenario 7) — and purpose-scoping is also what stops one challenge satisfying another.
  • registerDefault. 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.
  • loginBegins 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/request
↩︎Response 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.

dataobjectalways
Envelope wrapper holding the acknowledgement object for the OTP request.
data.statusenumalways
Acknowledgement that the OTP challenge was accepted and queued for delivery. Always this fixed value regardless of whether the phone exists, so the client should treat 202 as success and prompt the user to enter the code, never inferring account existence.
  • acceptedThe 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.
POST/v1/auth/phone/otp/verifypublicVerify login OTP

phone 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”.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 choose
The phone number being logged in, E.164 (e.g. +15551234567). Identifies the existing phone user and resolves the tenant. OPTIONAL when a device_token cookie is presented — the mirror of POST /v1/auth/phone/otp/request, and inseparable from it: leaving this half required while the request half is optional would be worse than either alone, because step 1 would ask for nothing and step 2 would have nothing to send, so the user would type a valid code and watch sign-in fail. The same resolver serves both, so the two steps can never disagree about which number a login is for. An unresolvable cookie here returns this endpoint's OWN uniform 401 rather than importing the request half's 404 — reusing the local shape is what keeps an unusable cookie indistinguishable from a bad code. This is ADDRESSING only: the OTP still authenticates and amr stays [sms].
codebodystringrequiredout-of-band
The numeric one-time OTP the user must obtain from the SMS sent to their phone (the side channel), then paste here. Verified against the issued challenge; wrong/expired/consumed codes return invalid_credentials.
POST https://api.kerja.team/v1/auth/phone/otp/verify
↩︎Response 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.accessTokenJWTalways
Short-lived ES256 access token (amr=["sms"]) for the new phone-login session. Auto-captured by the tester as the active bearer token for subsequent authenticated calls.
data.tokenTypeenumalways
Token scheme to use in the Authorization header. Always the literal "Bearer".
  • BearerSend the access token as `Authorization: Bearer <accessToken>`.
data.expiresInintegeralways
Access-token lifetime in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Use to schedule a refresh before expiry.
data.userobjectalways
Minimal identity profile of the logged-in phone user for the resolved tenant.
data.user.idUUIDalways
The authenticated user's unique id. Feed into later user-scoped calls.
data.user.phonestringalways
The user's E.164 phone number that was verified for this login.
data.user.tenantIdUUIDalways
The resolved/active tenant membership selected for this session (0 memberships denies, 1 is taken, many resolves to the default). Establishes the tenant scope for subsequent calls.