switching clears the token & responses
No token
15

Signing in when you belong to no organization

Signing in

A complete account — a verified credential and a password — that holds NO membership of any kind can authenticate, yet has nowhere for a session to live; typically a member removed from their only organization. Sign-in answers a purpose-scoped bootstrapToken instead of tokens, and the caller founds their own organization with it. Routed here by the BODY of the sign-in answer (noOrganization: true), not by a setting. The signed-in counterpart is scenario 24. To reach the state: remove a member from their only tenant (scenario 55), then sign in as them.

  1. Optional: POST /v1/auth/registration/resume { email } or { phone } → state: awaiting_tenant, nextStep: POST /v1/auth/login — how a client that remembers only the identifier knows to show “Create your organization” rather than a login error
  2. POST /v1/auth/login → 200 { noOrganization: true, bootstrapToken, expiresIn } — no session, no refresh or device cookie, no access token. The same body ends every other credential sign-in (challenge/verify, mfa/verify, phone/otp/verify) when the chain clears and the account belongs nowhere
  3. POST /v1/auth/organizations/bootstrap { bootstrapToken, name } → 200 accessToken + refresh cookie, scoped to the organization just founded; the caller is its admin (amr: [bootstrap])
POST/v1/auth/registration/resumepublicOptional — confirm the state is awaiting_tenant

state: awaiting_tenant means the user is active, everything is proven, and there is NO membership of any kind. Its nextStep is still POST /v1/auth/login — and that is not a leftover: resume never hands back a credential, so it cannot mint the bootstrapToken itself; logging in genuinely is the next step. The STATE is what tells the UI to render “Create your organization”. Deliberately narrower than “no ACTIVE membership”: a user whose only membership is suspended still reports complete, and login reports the real reason.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Public, throttled per IP. Submit EXACTLY ONE of email / phone — the identifier the user started with is all they have to remember — and the resolver answers { state, method, maskedDestination, nextStep, canResend, expiresAt } with the step to render. It never returns a credential: no token, no code, no password reset. The state is DERIVED from facts that already exist (status, password hash, *_verified_at, live tokens, memberships), with precedence: a finished account outranks a stale token, a pending invitation outranks a half-finished self sign-up, an active membership outranks an invited one. SEVEN states: not_started (no user with that identifier → the sign-up form) · awaiting_phone_otp (phone, no user, a live register OTP → the OTP screen with resend) · awaiting_password (user, phone verified, no password, no membership → “finish setting up — choose a password”; the phone is re-challenged before a registrationToken is re-issued) · awaiting_email_code (email unverified — reported even when the code has LAPSED, with canResend: true) · awaiting_invitation (a membership is invited → “you have a pending invitation”) · awaiting_tenant (user active, everything proven, NO membership of any kind → “create your organization”, §136 / scenario 15 — its nextStep is still POST /v1/auth/login, because resume cannot mint the bootstrapToken and login answers noOrganization with it; this used to report complete and send the user to a login that answered 403) · complete (active, ≥1 active membership → “log in”; also a user whose only membership is SUSPENDED — login then reports the real reason). Deliberately an enumeration oracle (the same posture register's 409 email_already_registered already takes); CAPTCHA/bot defence remains a production prerequisite for this surface.

emailbodystringconditionalyou choose
The email the user began signing up with. Provide email OR phone — exactly one. Can resolve to any state that an email identity can reach: awaiting_email_code, awaiting_invitation, awaiting_tenant, complete, or not_started.
phonebodystringconditionalyou choose
The E.164 number the user began signing up with. Provide email OR phone — exactly one. This is the identifier that recovers awaiting_password — a phone-verified identity with no password and no membership, left between steps 2 and 3 of scenario 3 — and awaiting_phone_otp, a live register OTP with no identity yet; it reaches awaiting_invitation / awaiting_tenant / complete / not_started too.
→ POST https://api.kerja.team/v1/auth/registration/resume
↩︎Response guide — what comes back & what each value means6 fields

200 with the resolver's answer. It tells you WHERE YOU ARE — never a token, a code, or a password reset. The state is DERIVED from facts that already exist (users.status, password_hash, the *_verified_at columns, live tokens, memberships), never from a duplicate state table which would be a second truth and the first thing to drift.

data.stateenumalways
Which step to render. Precedence when facts overlap: a finished account beats a stale token; a pending invitation beats a half-finished self sign-up; an active membership beats an invited one.
  • not_started — No identity holds that identifier — show the sign-up form (POST /v1/auth/register, or POST /v1/auth/phone/otp/request).
  • awaiting_phone_otp — A live register OTP exists but no identity yet — show the OTP screen with resend; continue at POST /v1/auth/registration/phone/verify.
  • awaiting_password — The phone is proven but no password was chosen and no membership exists (scenario 3 step 3 pending) — “Finish setting up, choose a password”. Continuing costs a FRESH OTP first: the original registrationToken is short-lived by design and the flow may resume days later on another machine.
  • awaiting_email_code — The email is not yet verified (scenario 2 step 2 pending) — show the code screen + resend. Reported even when the code has EXPIRED, with canResend: true: “your code lapsed, get another” is the right screen, where “nothing in flight” would strand the user.
  • awaiting_invitation — An invited membership is waiting — continue at POST /v1/auth/invitations/accept.
  • awaiting_tenant — Active account, everything proven, but NO membership of any kind — typically a member removed from their only organization (§136, scenario 15). Render “Create your organization”. nextStep is still POST /v1/auth/login, and deliberately: resume never hands back a credential, and login is what answers noOrganization with the bootstrapToken for POST /v1/auth/organizations/bootstrap. (This state used to report complete, which sent the user to a login that answered 403.)
  • complete — Active account with at least one active membership — “your account is ready, log in”. Also reported for a user whose only membership is SUSPENDED; login then gives the real reason.
data.methodenumalways
Which channel the pending step uses.
  • email — The outstanding step is on the email channel.
  • phone — The outstanding step is on the phone channel.
data.maskedDestinationstringconditional
The masked identifier the pending challenge would go to (b••••@example.com, +••••••6789). NEVER the full identifier you supplied.
data.nextStepstringalways
The endpoint to call next, as a METHOD + path string (e.g. "POST /v1/auth/email/verify") — render the screen that drives it.
data.canResendbooleanalways
Whether a fresh challenge can be requested for the current state — true, for instance, on an expired email code.
data.expiresAttimestampconditional
When the live challenge for this state expires, if there is one.
POST/v1/auth/loginpublicLog in (identifier + password)

The credential is checked exactly as always; what differs is the answer. 200 { noOrganization: true, bootstrapToken, expiresIn } — READ THE BODY: branch on accessToken / challengeToken / mfaRequired / noOrganization / invitationPending before assuming the caller is signed in. Nothing session-shaped is minted — no session row, no refresh or device cookie, no SSO pointer, no access token, no tenant_id claim anywhere. The token opens exactly ONE door (token_use: bootstrap, BOOTSTRAP_TOKEN_TTL_SECONDS default 15 min) and is refused wherever an access token is expected — /internal/validate and POST /v1/auth/registration/complete included. NOT the same state as a pending invitation: when your only seat is an unaccepted invitation you get invitationPending + acceptToken instead (scenario 52), and the two tokens are not interchangeable. A caller whose only membership is SUSPENDED gets the real 403, not this.

⛓ 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.
POST/v1/auth/organizations/bootstrappublicFound your organization with the bootstrap token

Public BY NECESSITY — the caller has no session, which is the defect; the bootstrapToken is what authenticates them, and the subject comes from the verified token, never the body. 200 with the ordinary token shape plus the refresh_token cookie (the accessToken is auto-captured), the session issued INTO the organization just founded — named explicitly rather than resolved as a default, so a concurrent invitation acceptance cannot land you somewhere you did not ask for. amr: [bootstrap]. CAPPED at one live founded organization per identity → 409 organization_already_owned; that same cap is what stops the token being replayed into a second organization inside its TTL (there is no single-use marker on the token). Once you belong somewhere, login answers normally and this answers 409. NO DEVICE TRUST: the body takes no trustDevice (.strict() refuses one) — the next ordinary login earns it. 401 invalid_credentials for a missing / expired / wrong-purpose token; 422 for a missing or over-200-char name or an unknown key; 429 over the per-user organization-creation cap.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

The way out for a caller who belongs to NO organization (§136) — typically a member removed from their only one, who can authenticate but has nowhere for a session to live. PUBLIC BY NECESSITY: the caller has no session, which is the defect; the bootstrapToken authenticates them and the subject comes from the verified token, never the body. No step-up. 200 with the ordinary token shape plus the refresh_token cookie — the session is issued INTO the organization just founded (named explicitly, so a concurrent invitation acceptance cannot land you elsewhere), you are its admin, and amr is [bootstrap]. Capped exactly as POST /v1/me/organizations is — one live founded organization per identity → 409 organization_already_owned, which also stops the token being replayed into a second organization inside its TTL (there is no single-use marker). Once you belong somewhere, login answers normally and this answers 409. NO DEVICE TRUST: the body takes no trustDevice (.strict() refuses one); the next ordinary login earns it. 429 over the per-user organization-creation cap. Walk-through: scenario 15.

bootstrapTokenbodystringrequiredfrom a response
The purpose-scoped token (token_use: bootstrap) from the noOrganization login answer — POST /v1/auth/login (or a challenge/verify, mfa/verify or phone/otp/verify that clears) returns 200 { noOrganization: true, bootstrapToken, expiresIn } instead of tokens. The tester shows it in that card's capture line — paste it here. Lives BOOTSTRAP_TOKEN_TTL_SECONDS (default 15 min). Missing, invalid, expired, of the wrong purpose (an acceptToken will NOT do), or naming an unknown user → 401 invalid_credentials.
namebodystringrequiredyou choose
The display name of the organization to found, 1–200 chars. Missing, over 200, or any unknown key in the body → 422.
→ POST https://api.kerja.team/v1/auth/organizations/bootstrap
↩︎Response guide — what comes back & what each value means6 fields

200 OK, { data } envelope — the ordinary token shape plus Set-Cookie: refresh_token (httpOnly). The way out for a caller who belongs to no organization (scenario 15): the bootstrapToken from the noOrganization login outcome founds their organization and the session is issued INTO it, named explicitly rather than resolved as a default. amr is [bootstrap] — not a new credential class, just an honest trail (the token was minted from a verified password or OTP). NO device trust and no device_token cookie (the next ordinary login earns it). Capped like POST /v1/me/organizations: 409 organization_already_owned once the caller has founded a live organization, which is also what stops a replay of the token. 401 invalid_credentials for a missing, expired, wrong-purpose or unknown-user token.

data.accessTokenstring (JWT)always
The bearer token for the NEW organization, amr [bootstrap]; auto-captured by this tester as the active bearer.
data.tokenTypeenumalways
Token scheme.
  • Bearer — Send as Authorization: Bearer <accessToken>.
data.expiresInnumberalways
Access-token TTL in seconds.
data.user.idUUIDalways
The caller — taken from the verified token, never the body.
data.user.tenantIdUUIDalways
The organization just founded; the caller is its admin and GET /v1/me now reports it as foundedTenantId.
Set-Cookie: refresh_tokencookiealways
The rotating refresh token, HttpOnly — an ordinary session from here on.