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 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/v1/auth/email/verify/resendpublicResend the email challenge (link + code)202 for an address still awaiting its REGISTRATION confirmation (an address awaiting an email CHANGE is resent by POST /v1/me/email-change/resend instead, scenario 30); 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. REGISTRATION-ONLY: re-issues the email challenge for a NOT-YET-VERIFIED sign-up address, retiring the challenge it replaces and re-minting BOTH halves together (a fresh 24h link and a fresh 15 min code) — one address has at most one live email-verify challenge, so a user who resends because they fear the first message was intercepted really does invalidate it. An address awaiting an email CHANGE is resent through POST /v1/me/email-change/resend (scenario 30), not here. Per-IP throttle, fail-closed, mirroring registration → 429.
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 active email from sign-up that is still unconfirmed. Registration-only (§153): an address awaiting an email CHANGE is resent through POST /v1/me/email-change/resend, not here. Nothing to send (unknown, or already verified) → 409 with an OR-list message that does not disclose which applied. Per-IP throttle; a 429 carries Retry-After.
data.statusenumalwaysaccepted— The only value: the resend went out. Collect the new code (or open the new link) for POST /v1/auth/email/verify.
/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 }. A 200 HERE IS A BODY TO READ, NOT A STATUS TO TRUST — five answers share it: { accessToken, … } (signed in — auto-captured as the active bearer), { challengeToken, challenge, remaining } (a device and/or MFA hop is pending — scenario 11/12/13), the legacy { mfaRequired, method, mfaToken } shape when DEVICE_TRUST_ENABLED is off (scenario 12), { noOrganization: true, bootstrapToken, expiresIn } (the credential is good but you belong nowhere — scenario 15), or { invitationPending: true, acceptToken, expiresIn, invitations[] } (your only seat is an invitation you have not accepted — scenario 52). The last two mint NOTHING session-shaped: no session, no refresh or device cookie, no access token. 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. A 200 is a BODY TO READ, not a status to trust — five shapes share it: tokens, a challenge chain (scenario 11–13), the legacy MFA challenge, { noOrganization, bootstrapToken } when the credential is good but you belong nowhere (scenario 15 — paste the token into POST /v1/auth/organizations/bootstrap), and { invitationPending, acceptToken, invitations[] } when your only seats are unaccepted invitations (scenario 52 — paste the token into POST /v1/auth/invitations/accept). The last two mint nothing session-shaped. 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 means23 fields
200 OK, { data } envelope, Cache-Control: no-store. A 200 here is a response you must READ, not a status you may trust: FIVE outcomes share it, and the login response itself is the only way to learn which 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; (4) NO ORGANIZATION { noOrganization: true, bootstrapToken, expiresIn } — the credential is good but the caller belongs nowhere (§136, scenario 15), continue at POST /v1/auth/organizations/bootstrap; (5) INVITATION PENDING { invitationPending: true, acceptToken, expiresIn, invitations[] } — every membership the caller holds is an unaccepted invitation (§139, scenario 52), continue at POST /v1/auth/invitations/accept. Branches 4 and 5 mint NOTHING session-shaped: no session row, no refresh or device cookie, no access token, no tenant_id claim. Only the token branch sets Set-Cookie: refresh_token. A caller whose only membership is suspended (or a mix of suspended and invited) is offered neither sessionless branch and still gets 403.
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.mfaTokenstringconditionaldata.noOrganizationbooleanconditionaltrue— Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditionaldata.invitationPendingbooleanconditionaltrue— Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditionaldata.invitationsarrayconditionaldata.invitations[].tenantIdUUIDconditionaldata.invitations[].tenantNamestringconditional