switching clears the token & responses
No token
2

Signing up with email

Signing up

A 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.

  1. 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)
  2. POST /v1/auth/email/verify → submit either half → verified + active, and the response carries an accessToken + refresh cookie
  3. POST /v1/auth/email/verify/resend → only if a half lapsed: re-mints BOTH halves together
  4. POST /v1/auth/login → optional; step 2 already signed you in
POST/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.

⛓ 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 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.statusenumalways
Acknowledgement that the sign-up was accepted. The account cannot log in until the email is confirmed at POST /v1/auth/email/verify (link or code).
  • accepted — 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
Human-readable confirmation, e.g. "Account created — verification link sent". Display only; do not branch on it.
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 {}. 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.

⛓ 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). 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-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 mint one with the “Mint email-verify token” helper — and paste it here. Supply this OR { email, code }, never both (422). 1–512 chars. Invalid or expired → 401 invalid_credentials.
emailbodystringconditionalyou choose
The address the short code was sent to — the SIGN-UP address. Required when redeeming by short code (a code is not unique, so the email is what scopes the lookup); omit it when submitting the link token. Trimmed, valid email, ≤ 320 chars.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the same message as the link (trimmed, 1–16 chars), 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. The code of an email CHANGE does not work here — it belongs to POST /v1/me/email-change/verify.
→ POST https://api.kerja.team/v1/auth/email/verify
↩︎Response 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.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.
Set-Cookie: device_tokencookieconditional
Session branch only. Device trust for the browser that redeemed the link / code — which, for an emailed link, may not be the browser that began the sign-up. The next login from this browser skips the new-device challenge.
POST/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.

Request body (JSON)
ℹ︎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 choose
The sign-up address still awaiting its registration confirmation → 202 and a fresh challenge. Nothing to send — an unknown address OR one already verified — → 409, with an OR-list message that does not disclose which applied. A pending change address is not looked up here any more (there is no pending_email staging). Valid email, at most 320 chars.
→ POST https://api.kerja.team/v1/auth/email/verify/resend
↩︎Response 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.statusenumalways
A fresh challenge was minted and delivered out-of-band — BOTH halves together (a new 24h link and a new 15 min code), so the newest message is internally consistent and any earlier code or link is dead.
  • accepted — The only value: the resend went out. Collect the new code (or open the new link) for POST /v1/auth/email/verify.
POST/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).

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 choose
The credential being authenticated: an email OR an E.164 phone number. The SHAPE decides the lookup, not a mode flag — anything matching ^\+[1-9]\d{6,14}$ is resolved by phone, anything else as an email — so a client never has to know which kind of account it is addressing. The credential you type must itself be VERIFIED (an email login needs a verified email, a phone login a verified phone). OPTIONAL ON A RECOGNISED DEVICE: when the body carries no identifier and a valid device_token cookie is presented, the account is resolved from the trusted device and its primary channel is used. That is what lets the “Welcome back” screen (scenario 8) ask for nothing but a password — it only ever had a MASKED identifier to show, and could not otherwise complete the login it had just offered. It grants nothing: the password is still required, and a missing / garbage / expired / revoked cookie with no identifier answers the SAME uniform 401 invalid_credentials as a wrong password (never a 422 “identifier is required”, which would itself confirm the cookie was rejected). An explicit identifier always takes precedence.
emailbodystringconditionalyou choose
DEPRECATED ALIAS for identifier, still accepted and normalised server-side so existing clients keep working. Send identifier in new integrations. Valid email, at most 320 chars.
clientIdbodystringoptionalyou choose
RESERVED and currently REJECTED with 422. The field exists so per-app tokens can land later without a route change — leave it out.
passwordbodystringrequiredyou choose
The account password the user types. 1-1024 chars. Verified against the stored credential; wrong password yields a uniform invalid_credentials 401.
→ POST https://api.kerja.team/v1/auth/login
↩︎Response 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.accessTokenstringconditional
Non-MFA branch only (MFA disabled). The signed JWT access token; the client sends it as Authorization: Bearer for subsequent calls.
data.tokenTypestringconditional
Non-MFA branch only. Token scheme to use in the Authorization header.
  • Bearer — Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Token branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600) — refresh before it elapses. No-organization / invitation-pending branches: the lifetime of the bootstrapToken / acceptToken instead — use it before it lapses, or log in again for a fresh one.
data.user.idstringconditional
Non-MFA branch only. The authenticated user's unique id.
data.user.emailstringconditional
Non-MFA branch only. The authenticated user's email.
data.user.displayNamestringconditional
Non-MFA branch only. Human-readable display name for the user, e.g. for UI greeting.
data.user.tenantIdstringconditional
Non-MFA branch only. The selected tenant for this session; if the user has multiple tenants, the default membership is chosen.
data.challengeTokenstringconditional
CHALLENGE branch only. The token that carries the ordered remaining plan and the accumulated amr — the server keeps no per-chain state. Copy it into POST /v1/auth/login/challenge/verify (or .../resend). Short-lived: LOGIN_CHALLENGE_TTL_SECONDS, default 300s.
data.challenge.typeenumconditional
CHALLENGE branch only. Which factor the first hop collects. A device hop always comes before an MFA hop, so the MFA hint is never leaked to an unrecognised machine.
  • device_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.hintstringconditional
CHALLENGE branch only. The masked destination the code went to (b••••@example.com, +••••••6789) — show it so the user knows which inbox or handset to check.
data.challenge.expiresInnumberconditional
CHALLENGE branch only. Seconds until this hop expires; when it lapses, start again at login.
data.remainingnumberconditional
CHALLENGE branch only. How many hops are still to clear, counting this one: 1 for a lone device or MFA challenge, 2 for the new-device-plus-MFA chain (LN3, scenario 13).
data.methodenumconditional
LEGACY MFA branch only. Which second factor to collect.
  • phone_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.phoneHintstringconditional
LEGACY MFA branch, phone_otp only. The masked destination the second-factor OTP will go to.
data.mfaRequiredbooleanconditional
MFA branch only (MFA enabled). Signals no token was issued and the caller must complete the MFA challenge via POST /v1/auth/mfa/verify (§3.8).
  • true — MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional
MFA branch only. A single-purpose JWT (~5 min TTL), NOT an access token. The client copies it into the follow-up MFA verify (and OTP request) calls to complete login.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — 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.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.