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 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.
POST/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.

Request body (JSON)
ℹ︎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 choose
The address awaiting confirmation. The lookup spans BOTH a not-yet-verified active email (first-time sign-up) and a staged pending_email from an email change, so a resend to the pending address works even while the current active email stays verified. Something to send → 202; nothing to send (unknown email OR an already-verified address that is not a staged pending one) → 409, with an OR-list message that does not disclose which applied. 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 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
Always "accepted". 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.
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 }. 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).

⛓ 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. 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 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 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.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.
  • BearerSend the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Non-MFA branch only. Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600). Client should refresh before it elapses.
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_codeNew browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.
  • device_phone_otpNew browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.
  • mfa_phone_otpAn enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.
  • mfa_totpAn 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_otpSend/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.
  • totpRead 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).
  • trueMFA 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.