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 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 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.
  • register — 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/request
↩︎Response 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.

dataobjectalways
Envelope wrapper holding the acknowledgement object for the OTP request.
data.statusenumalways
Acknowledgement that the OTP was issued and queued for delivery. Prompt for the code next: POST /v1/auth/phone/otp/verify (login), or the registration verify for purpose register.
  • accepted — The only value on a 202: a notification.otp_requested outbox event was emitted for the external notifier to send the WhatsApp/SMS code.
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 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”.

⛓ 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 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)conditional
Session branch. The bearer token for the tenant surface; auto-captured by this tester as the active bearer. amr is ["sms"].
data.tokenTypeenumconditional
Session branch. Token scheme for the Authorization header.
  • Bearer — Send the access token as Authorization: Bearer <accessToken>.
data.expiresInnumberconditional
Session branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Sessionless branches: the lifetime of the bootstrapToken / acceptToken.
data.user.idUUIDconditional
Session branch. The authenticated user's id.
data.user.phonestringconditional
Session branch. The E.164 number the OTP was proven on.
data.user.tenantIdUUIDconditional
Session branch. The resolved tenant for this session.
Set-Cookie: refresh_tokencookieconditional
Session branch only. The rotating refresh token, HttpOnly.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — 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.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.