Signing up with a phone number
Signing upA new user signs up with a phone number; this also creates their tenant + founding admin (full parity with email sign-up). Since Enhancement 4 it is a three-step flow that ends in a real password — the account is no longer password-less.
- POST /v1/auth/phone/otp/request (purpose=register) → 202 + OTP; 409 phone_already_registered if the number is taken
- POST /v1/auth/registration/phone/verify → identity with a VERIFIED phone, no password, no tenant yet → registrationToken (15 min)
- POST /v1/auth/registration/complete → password + tenant + founding admin + session (amr=[pwd,sms])
/v1/auth/phone/otp/requestpublicRequest sign-up OTP202 + an OTP if the phone is free; 409 phone_already_registered if the number is taken (deliberate reveal). Policy-gated: if phone isn't in the active policy → 403 registration_method_disabled. Stays open even under atomic — that OTP is what proves the phone inside the combined call. Single-use, ~5 min TTL, attempt-capped; re-requesting replaces the prior live challenge for that phone.
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/registration/phone/verifypublicVerify the phone (step 2 of 3)Creates the identity with the phone VERIFIED, no password, status pending_verification, login_mode='phone' — and deliberately NO tenant and NO membership, so a sign-up abandoned between the halves leaves an inert identity rather than an orphan tenant. Returns a short-lived registrationToken (REGISTRATION_TOKEN_TTL_SECONDS, default 900s). Purpose-scoped: a login or device OTP cannot satisfy registration. An identity left by an earlier abandoned attempt is RESUMED, not rejected — only a completed account (one with a password) answers 409 phone_already_registered.
Field guide — what each value means & where it comes from2 fields
Step 2 of 3 of phone sign-up (R1). Public, pre-authentication, throttled. Verifies the register-purpose OTP and creates the identity with a VERIFIED phone, no password, status pending_verification and login_mode='phone' — and deliberately no tenant and no membership, so a sign-up abandoned between the halves leaves an inert identity rather than an orphan tenant. Returns a short-lived registrationToken.
phonebodystringrequiredyou choosecodebodystringrequiredout-of-bandPOST https://api.kerja.team/v1/auth/registration/phone/verifyResponse guide — what comes back & what each value means2 fields
200 with the bridge token for R1's second half. Creates the identity with a VERIFIED phone, no password, status pending_verification and login_mode='phone' — and deliberately NO tenant and NO membership, so an abandoned sign-up leaves an inert identity rather than an orphan tenant. That is the entire reason R1 is two calls instead of one.
data.registrationTokenstringalwaysdata.expiresInnumberalways/v1/auth/registration/completepublicChoose a password (step 3 of 3)Sets the password, bootstraps the tenant + founding admin membership, activates the account and issues the session (amr: [pwd,sms] — the phone was proven at step 2, the password now). data.accessToken is auto-captured. Refuses a REPLAYED token once a password exists (409), so one registration can never bootstrap two tenants — and the guarantee holds under CONCURRENCY too, because the user row is read FOR UPDATE inside the transaction, so two simultaneous completions with the same token are serialised rather than both bootstrapping and the loser dying on a foreign key. clientId is no longer reserved: it names the app this sign-up arrived from, and when it resolves to an ACTIVE PRODUCT app whose isAutoEnabledOnRegister flag is on (scenario 41), this same transaction also enables the new tenant for that app and grants the founder it, so the backfill projects the pair within one poll interval with no operator step. THAT SHORTCUT IS FOR A VISITOR WITH NO ACCOUNT: someone who already has one never reaches it — they are refused 409 email_already_registered / phone_already_registered — and acquires the same app at scenario 50, which gates on the same four registry predicates. Any other clientId — unknown, inactive, identity-kind, or the flag off — registers normally and enables nothing, deliberately without saying which. Interrupted between steps 2 and 3? That is the awaiting_password state — scenario 6 recovers it from the phone number alone.
Field guide — what each value means & where it comes from5 fields
Step 3 of 3 of phone sign-up (R1). Public, pre-authentication. Sets the password, bootstraps the tenant + founding admin membership, activates the account and issues the session (amr [pwd, sms] — the phone was proven at step 2, the password now). Refuses a REPLAYED token once a password exists (409), so one registration can never bootstrap two tenants.
registrationTokenbodystringrequiredfrom a responsepasswordbodystringrequiredyou choosedisplayNamebodystringoptionalyou chooseorganizationNamebodystringoptionalyou chooseclientIdbodystringoptionalyou choosePOST https://api.kerja.team/v1/auth/registration/completeResponse guide — what comes back & what each value means5 fields
200 with the ordinary session shape, plus the refresh_token cookie. This is where the tenant and the founding admin membership are finally created and the account is activated. amr is [pwd, sms] — the phone was proven at step 2, the password now.
data.accessTokenstringalwaysdata.tokenTypestringalwaysdata.expiresInnumberalwaysdata.userobjectalwaysSet-Cookie: refresh_tokencookiealways/v1/auth/phone/registerpublicOne-shot phone sign-up (DEPRECATED)DEPRECATED — the pre-Enhancement-4 one-shot that creates a PASSWORD-LESS account (user + tenant + founding admin) in a single call and returns tokens (amr=[sms]). Kept so existing clients do not break; new integrations should use the two-step path above. Policy-gated, and refused with 403 registration_combination_required while combinationMode=atomic. It also returns a device_token cookie on the same reasoning as step 3 — the OTP was typed into THIS browser — so trustDevice defaults to true; send false on a shared machine to decline it.
Field guide — what each value means & where it comes from5 fields
Public phone sign-up — no Authorization needed. The body is .strict(): only the four listed fields are accepted. You must already have completed an OTP-request step and obtained the SMS code out-of-band.
phonebodyE.164requiredyou choosecodebodystringrequiredout-of-banddisplayNamebodystringoptionalyou chooseorganizationNamebodystringoptionalyou choosetrustDevicebodybooleanoptionalyou choosePOST https://api.kerja.team/v1/auth/phone/registerResponse guide — what comes back & what each value means8 fields
200 OK with the standard { data } envelope; issues an inline session for the new phone-only user. Response headers: Set-Cookie: refresh_token (httpOnly) and Cache-Control: no-store. The refresh token is delivered only via cookie, not in the body.
data.accessTokenJWTalwaysdata.tokenTypeenumalwaysBearer— Token is sent in the Authorization header as 'Bearer <accessToken>'.
data.expiresInintegeralwaysdata.userobjectalwaysdata.user.idUUIDalwaysdata.user.phonestringalwaysdata.user.displayNamestringconditionaldata.user.tenantIdUUIDalways