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 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-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.
  • emailSign-up may use an email address (render the email field).
  • phoneSign-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.
  • linkDefault — register with a single method now and add the second method later under /v1/me.
  • atomicA 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 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/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 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.statusenumalways
Acknowledgement that the registration request was accepted for asynchronous processing. The client treats this as 'check your email' — the account cannot log in until the out-of-band email-verify token is used (§3.12). This value is returned identically for both new and already-existing emails (anti-enumeration), so the client must not infer account existence from it.
  • acceptedThe 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).
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 {}.

⛓ 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). 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-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 after staging a new email (scenario 28) and paste it here. Supply this OR { email, code }, never both — the first one wins and the second returns 401. 1–512 chars.
emailbodystringconditionalyou choose
The address being verified. Required when redeeming by short code (the code alone does not identify the row); omit it when submitting the link token.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the same message as the link, 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.
POST https://api.kerja.team/v1/auth/email/verify
↩︎Response 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.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.