Signing up with email
Signing upA new user signs up with email + password, which also creates their organization (tenant) and makes them its founding admin. Email verification is required before the first login — and verification itself now ends in a session.
- POST /v1/auth/register → 202 (tenant + founding admin; one challenge row carrying a 24h link AND a 15 min 6-digit code goes out-of-band)
- POST /v1/auth/email/verify → submit either half → verified + active, and the response carries an accessToken + refresh cookie
- POST /v1/auth/email/verify/resend → only if a half lapsed: re-mints BOTH halves together
- POST /v1/auth/login → optional; step 2 already signed you in
/v1/auth/registerpublicRegister (self-service sign-up)Creates the identity, a tenant, an active default membership and the founding admin role. A NEW email → 202; a TAKEN email → 409 email_already_registered (deliberate reveal, no silent no-op). Idempotency-Key is optional (the unique email is the business key); when supplied it is honoured durably. Under combinationMode=atomic the body MUST also carry { phone, code } (scenario 5) and an email-only body → 403 registration_combination_required; under link a combined body is refused 422 rather than silently dropping the phone. Interrupted here? Scenario 6 picks the flow back up from the email alone.
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/v1/auth/email/verify/resendpublicResend the email challenge (link + code)202 for an address still awaiting confirmation (a not-yet-verified active email OR a staged pending_email); 409 when there is nothing to send (unknown email OR already verified) — an OR-list message that never discloses which. Always re-mints BOTH halves together, so the newest message is internally consistent.
Field guide — what each value means & where it comes from1 field
Public. Re-issues the email challenge for an address that is not yet confirmed, always re-minting BOTH halves together (a fresh 24h link and a fresh 15 min code) so the newest message is internally consistent. Per-IP throttle, fail-closed, mirroring registration.
emailbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/email/verify/resendResponse guide — what comes back & what each value means1 field
202 Accepted with a uniform body when there IS something to send — an unconfirmed active email or a staged pending_email. Nothing to send (unknown address, or an already-verified address that is not a staged pending one) → 409 with an OR-list message that does not disclose which applied. Per-IP throttle; a 429 carries Retry-After.
data.statusstringalways/v1/auth/loginpublicLog in (identifier + password)identifier is an email OR an E.164 phone — the shape decides the lookup ({ email } is still accepted as a deprecated alias) — and it is OPTIONAL on a recognised device, where the device_token cookie names the account and the body needs only { password }. Three possible answers: { accessToken, … } (nothing left to prove — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — continue at scenario 11/12/13), or the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12). Gated by the LOGIN policy before the credential is even checked → 403 login_method_disabled (scenario 7).
Field guide — what each value means & where it comes from4 fields
Public, unauthenticated login. No Authorization header; send only the JSON body below. Throttled per credential/IP; response is no-store. The answer is one of three shapes — tokens, a challenge chain, or the legacy MFA challenge — and which one you get is NOT checkable beforehand (there is no pre-login MFA-status endpoint, by anti-enumeration design). Before the credential is examined at all, the active LOGIN policy is checked: if the channel this attempt uses is off, the answer is 403 login_method_disabled for a right and a wrong password alike (which discloses nothing — GET /v1/auth/login-policy publishes the same fact unauthenticated). Read that policy first in scenario 7.
identifierbodystringconditionalyou chooseemailbodystringconditionalyou chooseclientIdbodystringoptionalyou choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/loginResponse guide — what comes back & what each value means16 fields
200 OK, { data } envelope, Cache-Control: no-store. THREE possible shapes, and the login response itself is the only way to learn which branch applies (there is no pre-login MFA-status endpoint, by anti-enumeration design): (1) tokens — nothing more to prove; (2) a CHALLENGE CHAIN { challengeToken, challenge, remaining } when a device and/or MFA hop is pending, continued at POST /v1/auth/login/challenge/verify; (3) the LEGACY single-hop { mfaRequired, method, phoneHint, mfaToken } when DEVICE_TRUST_ENABLED is off. Only the token branch sets Set-Cookie: refresh_token.
data.accessTokenstringconditionaldata.tokenTypestringconditionalBearer— Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditionaldata.user.idstringconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdstringconditionaldata.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_email_code— New browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.device_phone_otp— New browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.mfa_phone_otp— An enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.mfa_totp— An enrolled authenticator app — nothing is sent; the code comes from the app, so no resend applies.
data.challenge.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.methodenumconditionalphone_otp— Send/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.totp— Read the code from the authenticator app — there is nothing to send, so skip the otp/request step.
data.phoneHintstringconditionaldata.mfaRequiredbooleanconditionaltrue— MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional