switching clears the token & responses
No token
3

Signing up with a phone number

Signing up

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

  1. POST /v1/auth/phone/otp/request (purpose=register) → 202 + OTP; 409 phone_already_registered if the number is taken
  2. POST /v1/auth/registration/phone/verify → identity with a VERIFIED phone, no password, no tenant yet → registrationToken (15 min)
  3. POST /v1/auth/registration/complete → password + tenant + founding admin + session (amr=[pwd,sms])
POST/v1/auth/phone/otp/requestpublicRequest sign-up OTP

202 + 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.

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

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 choose
The E.164 number being registered — the same one the OTP was requested for at POST /v1/auth/phone/otp/request. An identity left behind by an earlier abandoned attempt is RESUMED, not rejected: only a completed account (one that already has a password) answers 409 phone_already_registered.
codebodystringrequiredout-of-band
The 6-digit OTP delivered by SMS/WhatsApp. PURPOSE-SCOPED: it must be a register-purpose code — a login or device OTP cannot satisfy registration. Single-use, ~5 min TTL, attempt-capped.
POST https://api.kerja.team/v1/auth/registration/phone/verify
↩︎Response 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.registrationTokenstringalways
The short-lived purpose token to submit to POST /v1/auth/registration/complete. Not a session and not an access token — it authorizes exactly one thing: choosing a password for this pending identity.
data.expiresInnumberalways
Token lifetime in seconds (REGISTRATION_TOKEN_TTL_SECONDS, default 900). If it lapses, recover from the phone number alone via POST /v1/auth/registration/resume, which reports awaiting_password.
POST/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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 response
The token POST /v1/auth/registration/phone/verify returned (or one minted by the registration-token helper). Short-lived — REGISTRATION_TOKEN_TTL_SECONDS, default 900s. If it lapsed, do not hunt for it: resume from the phone number (scenario 6) and re-prove possession, which is exactly why resume never re-issues this token.
passwordbodystringrequiredyou choose
The password the user freely chooses, argon2-hashed server-side. This is the step that makes the account no longer password-less, so it can subsequently sign in with phone + password (scenario 10).
displayNamebodystringoptionalyou choose
Optional human-readable profile name. Trimmed, 1–200 chars, NFC-normalized.
organizationNamebodystringoptionalyou choose
Optional name for the tenant this call bootstraps. Trimmed, 1–200 chars, NFC-normalized.
clientIdbodystringoptionalyou choose
SELF-SERVICE APP ENABLEMENT — no longer reserved. Names the app this sign-up arrived from (the register hand-off); slug-validated, max 64, ^[a-z0-9]+(-[a-z0-9]+)*$, malformed → 422. When it resolves to an ACTIVE PRODUCT app whose isAutoEnabledOnRegister flag is on (set at PATCH /v1/admin/client-apps/{code}, 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 — audited as tenant_app_enabled + app_grant_granted with reason auto_enable_on_register…, attributed to the founder. This branch only ever runs for a visitor with NO account; an existing user is refused 409 …_already_registered here and joins the same app at POST /v1/me/app-joins (scenario 50), audited under its own reason self_join_existing_account so the two are never indistinguishable in the trail. Any other clientId — unknown, inactive, identity-kind, or the flag off — registers normally and enables nothing, DELIBERATELY WITHOUT DISTINGUISHING WHICH: the surface enumerates no client ids, and a stale sign-up link must never block account creation.
POST https://api.kerja.team/v1/auth/registration/complete
↩︎Response 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.accessTokenstringalways
The bearer token for the tenant surface, auto-captured by this tester.
data.tokenTypestringalways
Always "Bearer".
data.expiresInnumberalways
Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600).
data.userobjectalways
The newly activated user: id, phone, displayName, and the tenantId of the tenant this call bootstrapped.
Set-Cookie: refresh_tokencookiealways
The rotating refresh token, HttpOnly. A REPLAYED registrationToken does not reach this shape — once a password exists the call answers 409, so one registration can never bootstrap two tenants.
POST/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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 choose
The phone number to register, in E.164 format (e.g. +15551234567). The user types this; it must match the number the SMS OTP was sent to.
codebodystringrequiredout-of-band
The numeric one-time code (default 6 digits) delivered via SMS to the phone number. The user must read it from the text message and paste it; it must be unconsumed and unexpired.
displayNamebodystringoptionalyou choose
Optional human-readable name for the new user (trimmed, 1–200 chars, NFC-normalized). The user freely chooses it.
organizationNamebodystringoptionalyou choose
Optional name for the new tenant/organization bootstrapped for the registrant (trimmed, 1–200 chars, NFC-normalized). The user freely chooses it.
trustDevicebodybooleanoptionalyou choose
DEFAULTS TO TRUE. The OTP was typed into THIS browser, so completing the call earns it a device_token cookie on the same reasoning as POST /v1/auth/registration/complete — the next sign-in here raises no new-device challenge. Send false on a shared machine to decline the grant.
POST https://api.kerja.team/v1/auth/phone/register
↩︎Response 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.accessTokenJWTalways
Signed access token (amr=["sms"]) for the new phone-only user. Auto-captured by the tester as the active bearer token for subsequent authenticated calls.
data.tokenTypeenumalways
Scheme to use when sending accessToken; always literally Bearer (send as Authorization: Bearer <token>).
  • BearerToken is sent in the Authorization header as 'Bearer <accessToken>'.
data.expiresInintegeralways
Access-token lifetime in seconds (e.g. 600 = 10 minutes). Client refreshes before this elapses using the refresh_token cookie.
data.userobjectalways
The newly created phone-only identity together with its freshly bootstrapped tenant context.
data.user.idUUIDalways
Unique identifier of the new user. Feed into later user-scoped calls.
data.user.phonestringalways
The verified phone number in E.164 format (phoneVerifiedAt is set); email and password are null for this phone-only identity.
data.user.displayNamestringconditional
The user's display name. Present only when displayName was supplied in the request body; omitted/absent otherwise.
data.user.tenantIdUUIDalways
Identifier of the registrant's own newly created tenant (bootstrapped provisioning -> active with a founding admin role). Use as the active tenant context for tenant-scoped calls.