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 39).
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 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/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 means1 field
202 Accepted with the standard { data } envelope; body is the uniform anti-enumeration acknowledgement { "data": { "status": "accepted" } } — byte-for-byte identical whether or not the email already existed. No tokens, no user object, and no notable response headers (no Set-Cookie; the email-verify token is delivered out-of-band, never in the body).
data.statusenumalwaysaccepted— The only possible value; signals the 202 request was accepted for processing (tenant/admin provisioning or a 'you already have an account' notice happens out-of-band).
/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 {}.
Field guide — what each value means & where it comes from3 fields
Public endpoint (no Authorization, no step-up, no Idempotency-Key). Submit EITHER the link token OR the short code — they are two representations of ONE verification_tokens row, so redeeming either marks the row used and kills the other. Two paths land here: first-time verification (registration, or the atomic sign-up's outstanding email), and a STAGED email change, where the pending_email is swapped in to become the active verified address.
tokenbodystringconditionalout-of-bandemailbodystringconditionalyou choosecodebodystringconditionalout-of-bandPOST https://api.kerja.team/v1/auth/email/verifyResponse guide — what comes back & what each value means5 fields
200 OK with the { data } envelope. The 200 means the challenge (link OR code — one row, either half) was valid and consumed: the email is marked verified, the account is set active, and a user.upserted outbox event is enqueued. SINCE ENHANCEMENT 4 VERIFICATION ENDS IN A SESSION: when the account already has an active membership the body carries an accessToken and a refresh_token cookie (amr [email]), so the old verify-then-log-in round-trip is unnecessary. This was an ADDITION to the existing 200 — an account with NO active membership (e.g. an invitee) still receives {}, so no existing caller breaks. A staged email change instead swaps the pending_email in as the active verified address.
data.accessTokenstringconditionaldata.tokenTypestringconditionaldata.expiresInnumberconditionaldata.userobjectconditionalSet-Cookie: refresh_tokencookieconditional