Signing up with email + phone (atomic mode)
Signing upA 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.
- GET /v1/auth/registration-policy → combinationMode: atomic → render ONE form asking for email, password and phone
- POST /v1/auth/phone/otp/request (purpose=register) → 202 + the OTP that will prove the phone inside the sign-up
- 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
- POST /v1/auth/email/verify → the email is verified too, the account becomes active, and a session is returned
/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-policyResponse 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" }.
dataobjectalwaysdata.allowedMethodsarrayalwaysdata.allowedMethods[]enumalwaysemail— 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.combinationModeenumalwayslink— 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.
/v1/auth/phone/otp/requestpublicRequest the inline sign-up OTPpurpose=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.
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 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 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.
dataobjectalwaysdata.statusenumalwaysaccepted— The only value on a 202: a notification.otp_requested outbox event was emitted for the external notifier to send the WhatsApp/SMS code.
/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).
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 choosepasswordbodystringrequiredyou choosephonebodystringconditionalyou choosecodebodystringconditionalout-of-banddisplayNamebodystringoptionalyou chooseorganizationNamebodystringoptionalyou choosePOST https://api.kerja.team/v1/auth/registerResponse 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.statusenumalwaysaccepted— 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/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.
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-bandemailbodystringconditionalyou choosecodebodystringconditionalout-of-bandPOST https://api.kerja.team/v1/auth/email/verifyResponse 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.accessTokenstringconditionaldata.tokenTypestringconditionaldata.expiresInnumberconditionaldata.userobjectconditionalSet-Cookie: refresh_tokencookieconditionalSet-Cookie: device_tokencookieconditional