switching clears the token & responses
No token
5

Signing up with email + phone (atomic mode)

Signing up

A single sign-up that supplies and verifies BOTH credentials at once: the phone proven inline by its OTP, the email proven out-of-band afterwards. Routed here when the operator has set combinationMode=atomic and allowedMethods holds both methods.

  1. GET /v1/auth/registration-policy → combinationMode: atomic → render ONE form asking for email, password and phone
  2. POST /v1/auth/phone/otp/request (purpose=register) → 202 + the OTP that will prove the phone inside the sign-up
  3. POST /v1/auth/register with { email, password, phone, code } → OTP verified inline and FIRST, identity created with the phone already verified, tenant + founding admin bootstrapped, email challenge sent → 202
  4. POST /v1/auth/email/verify → the email is verified too, the account becomes active, and a session is returned
GET/v1/auth/registration-policypublicRead the policy (expect atomic)

Render the combined form only when combinationMode is atomic AND allowedMethods holds both methods — atomic is inert with a single-method policy and then behaves as link. Set it from the operator profile (scenario 45).

ℹ︎Field guide — what each value means & where it comes fromno inputs

No request body, path params, query params, or required headers. This is a public, unauthenticated, read-only endpoint — no Authorization or x-gateway-context is required either. It returns the active self-registration policy (allowedMethods and combinationMode) so the sign-up UI can render the correct form.

→ GET https://api.kerja.team/v1/auth/registration-policy
↩︎Response guide — what comes back & what each value means4 fields

200 OK returning the standard { data } envelope; body is the active self-registration policy (no secrets, no user data). Response header Cache-Control: public, max-age=30 (lightly cacheable). If the policy row is missing it defaults to { allowedMethods: ["email"], combinationMode: "link" }.

dataobjectalways
Envelope wrapper holding the active self-registration policy the unauthenticated sign-up UI uses to render the correct form.
data.allowedMethodsarrayalways
The identifier methods the sign-up form may offer; a subset of ["email","phone"]. The client renders one input per listed method. Defaults to ["email"] when the policy row is missing.
data.allowedMethods[]enumalways
An allowed registration identifier method. Each value tells the UI which credential input to render.
  • email — Sign-up may use an email address (render the email field).
  • phone — Sign-up may use a phone number (render the phone field / OTP flow).
data.combinationModeenumalways
How the allowed methods combine during sign-up; drives whether the form collects one identifier or both at once. Defaults to "link" when the policy row is missing.
  • link — Default — register with a single method now and add the second method later under /v1/me.
  • atomic — A single register call must carry both methods together.
POST/v1/auth/phone/otp/requestpublicRequest the inline sign-up OTP

purpose=register. 202 + an OTP; 409 phone_already_registered if the number is taken. This entrypoint stays OPEN under atomic (unlike the single-channel sign-up calls) precisely because the combined body needs its code.

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/registerpublicRegister with email + phone (one call)

phone and code must be supplied TOGETHER (422 otherwise). The OTP is verified first, inside the transaction, BEFORE any row is created — a caller who cannot prove the number never reaches the email-taken reveal and leaves nothing behind. The phone is then stored already verified while the account stays pending_verification until the email is confirmed too: that is what distinguishes atomic from link. Under link (the default) this combined body is rejected 422. The resulting account is email-primary (login_mode: email).

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from6 fields

Public self-service sign-up that creates a new tenant and its founding admin user; no Authorization or step-up needed. A new email → 202; a TAKEN email → 409 email_already_registered (a deliberate reveal — “sign in instead” — not a silent no-op). Business-key idempotent on the unique email, so Idempotency-Key is OPTIONAL, and honoured durably when supplied. The email challenge (24h link + 15 min code, one row) is delivered out-of-band; the account cannot log in until it is verified. Under combinationMode=atomic the body must additionally carry { phone, code }.

emailbodystringrequiredyou choose
The user freely types the email for the new founding admin account. Trimmed, must be a valid email and at most 320 chars. The email-verification token is later sent to this address out-of-band.
passwordbodystringrequiredyou choose
The user freely chooses the new account password. Must be between 12 and 1024 chars and must not equal the email local-part (case-insensitive). Argon2-hashed server-side, before the DB writes.
phonebodystringconditionalyou choose
E.164 number, supplied ONLY under combinationMode=atomic, where it is REQUIRED — and rejected 422 under link, because quietly discarding a credential the caller believed they were registering is the worse failure. Must be supplied TOGETHER with code (422 otherwise): a phone with no proof, or a proof with no phone, is never a valid combination. Stored ALREADY VERIFIED, since re-proving it later would ask for a second possession proof of the same number in the same flow.
codebodystringconditionalout-of-band
The register-purpose OTP proving `phone` inline (atomic mode only), from POST /v1/auth/phone/otp/request. It is verified FIRST, inside the transaction, BEFORE any row is created — so a caller who cannot prove the number never reaches the email-taken reveal and leaves nothing behind. Consumed on success; exhausting its attempt cap consumes the challenge.
displayNamebodystringoptionalyou choose
Optional human-readable name the user types for their profile. Trimmed, 1–200 chars, NFC-normalized.
organizationNamebodystringoptionalyou choose
Optional name the user types for the new tenant/organization. Trimmed, 1–200 chars, NFC-normalized. Defaults to "<email>'s organization" if omitted.
→ POST https://api.kerja.team/v1/auth/register
↩︎Response guide — what comes back & what each value means2 fields

202 Accepted with the standard { data } envelope, for a NEW email only: the account, its tenant and founding admin are provisioned and a single challenge — a 24 h link and a 15 min 6-digit code — goes out-of-band. Not uniform: a taken email is an explicit 409 email_already_registered ("sign in instead"), never a silent 202. With an Idempotency-Key a genuine retry replays the stored 202 instead of creating a second tenant. No tokens, no user object, no Set-Cookie — the session comes at POST /v1/auth/email/verify (scenario 2).

data.statusenumalways
Acknowledgement that the sign-up was accepted. The account cannot log in until the email is confirmed at POST /v1/auth/email/verify (link or code).
  • accepted — The only value on a 202: a new account was created and the verification message sent. Next: collect the code (or open the link) for POST /v1/auth/email/verify.
data.messagestringalways
Human-readable confirmation, e.g. "Account created — verification link sent". Display only; do not branch on it.
POST/v1/auth/email/verifypublicVerify email (link token or short code)

Submit EITHER { token } (the 24h link) OR { email, code } (the 6-digit code, 15 min) — they are two halves of ONE challenge row, so redeeming either kills the other, and exhausting the code's attempt cap (EMAIL_CODE_MAX_ATTEMPTS, default 5) consumes the row and takes the link with it. On an account that already has an active membership the 200 carries an accessToken + refresh cookie (amr: [email]) — verification now ends in a session; an invitee with no active membership still gets {}. REGISTRATION-ONLY since §153: a change of address is confirmed by POST /v1/me/email-change/verify (scenario 30), never here — while an email change is live this answers 409 conflict, as it does for an already-verified address.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from3 fields

Public endpoint (no Authorization, no step-up, no Idempotency-Key). REGISTRATION-ONLY (§153 X-7): it confirms the address an account signed up with (scenario 2 / 5) — activating the account — and never applies a new one. An email CHANGE is confirmed by the code of POST /v1/me/email-change/verify (scenario 30), and while such a change is live for the account this route answers 409 conflict. The body is .strict() with EXACTLY ONE form: the link { token } OR the short code { email, code } — two representations of ONE verification_tokens row, so redeeming either marks the row used and kills the other; both forms, neither, or half a pair → 422. When the account has an active membership the 200 carries a session plus refresh_token and device_token cookies (redeeming the link or code proved the channel in this browser — no trustDevice opt-out); otherwise (e.g. an invitee) 200 { data: {} }. 409 also when the address is already verified.

tokenbodystringconditionalout-of-band
The single-use email-verification LINK token, valid 24h. The user does NOT choose this value; they obtain it from the verification message sent at registration (scenario 2 / 5) — or mint one with the “Mint email-verify token” helper — and paste it here. Supply this OR { email, code }, never both (422). 1–512 chars. Invalid or expired → 401 invalid_credentials.
emailbodystringconditionalyou choose
The address the short code was sent to — the SIGN-UP address. Required when redeeming by short code (a code is not unique, so the email is what scopes the lookup); omit it when submitting the link token. Trimmed, valid email, ≤ 320 chars.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the same message as the link (trimmed, 1–16 chars), valid EMAIL_CODE_TTL_SECONDS (default 15 min) — deliberately far shorter than the link's 24h, because ~20 bits of entropy must not sit live for a day. Attempt-capped (EMAIL_CODE_MAX_ATTEMPTS, default 5); exhausting the cap consumes the row and takes the LINK with it, forcing a resend via POST /v1/auth/email/verify/resend. The code of an email CHANGE does not work here — it belongs to POST /v1/me/email-change/verify.
→ POST https://api.kerja.team/v1/auth/email/verify
↩︎Response guide — what comes back & what each value means6 fields

200 OK with the { data } envelope. REGISTRATION-ONLY (§153): it confirms the address a sign-up (or a phone-only account's first email) registered; a change of address is confirmed by the code of POST /v1/me/email-change/verify, never here — while an email change is live this route answers 409. The 200 means the challenge (link OR code — one row, either half) was valid and consumed: the email is marked verified, the account set active, and user.upserted enqueued for every app the user holds in any of their active tenants. TWO shapes: when the account has an active membership the body carries a session (amr [email]) with Set-Cookie refresh_token AND device_token (the link or code proved the channel in this browser — no trustDevice opt-out) and Cache-Control: no-store; with no active membership (e.g. an invitee) it is a bare {} and no cookies.

data.accessTokenstringconditional
Present when the verified account has an active membership (the founding-admin case of scenario 2 / 5). Auto-captured by this tester as the active bearer token; amr is [email].
data.tokenTypestringconditional
Always "Bearer" when a token is issued.
data.expiresInnumberconditional
Access-token TTL in seconds (default 600) when a token is issued.
data.userobjectconditional
The now-active user (id, email, displayName, tenantId) when a session is issued.
Set-Cookie: refresh_tokencookieconditional
The rotating refresh token, HttpOnly — issued alongside the accessToken. Absent on the bare {} branch.
Set-Cookie: device_tokencookieconditional
Session branch only. Device trust for the browser that redeemed the link / code — which, for an emailed link, may not be the browser that began the sign-up. The next login from this browser skips the new-device challenge.