switching clears the token & responses
No token
50

Inviting a teammate by email

An admin adds someone to their tenant by email address; the invitee finishes sign-up themselves and lands ALREADY SIGNED IN. This is R4 (a tenant admin inviting) and R5 (the founding admin of an operator-provisioned tenant, scenario 41).

  1. POST /v1/invitations (admin) with { email } → 201 { userId } + a Location header; one invitation row carrying a 7-day link AND a 6-digit code, stamped channel = email, plus an invited membership
  2. POST /v1/auth/invitations/accept (invitee) → either { token, password } or { email, code, password } → membership activated, email auto-verified, accessToken + refresh cookie (amr: [pwd, email])
  3. PUT /v1/users/{userId}/roles/{role} (admin) → assign any role beyond bare membership — the invite itself grants none
POST/v1/invitationsadminInvite a user by email (admin)

201 with { userId } and a Location header. Exactly ONE identifier per invitation — a body carrying both email and phone is rejected 422, because the delivery channel (and therefore the audit trail) would be ambiguous. The membership is created in the INVITER's tenant and the invitee gets no tenant of their own. If the email already exists as an identity, the invite REUSES it rather than duplicating the account. At most one OPEN invite per (tenant, user) — a still-live duplicate is a genuine 409 (and really answers 409 now: the conflict had been surfacing as a 500 because Drizzle hangs the driver error off `cause`, so the constraint check read undefined and rethrew). “THEY NEVER GOT IT” → RESEND, DON'T RE-INVITE: POST /v1/invitations/{userId}/resend (scenario 54) re-delivers the pending invitation with fresh secrets, and is the only path that helps while an invitation is still open. An EXPIRED link is not a dead end here either: this endpoint retires a lapsed open invitation before minting a new one, so re-inviting someone whose link ran out just works. It has to, because the open-invite unique index is partial on (used_at IS NULL AND deleted_at IS NULL) and cannot consider expiry — `now()` is not immutable — so a lapsed invitation would otherwise hold its slot for ever. Removing the invited membership does not help either — it never touches the token row — which is why DELETE /v1/users/{userId} (scenario 55) revokes any outstanding invitation as it removes the membership: otherwise the invitation would outlive the membership it was issued for, and the invitee could redeem a perfectly valid link only to be told 403 no active membership.

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

Admin-only endpoint that invites a user into the caller's tenant, by email (R4/R5) or by phone over WhatsApp (R3). Requires a tenant-admin Authorization token plus the gateway context (both handled by the tester). Idempotency-Key is optional but honoured durably. The invitation is delivered out-of-band and carries BOTH a 7-day link and a 6-digit code, neither of which appears in the response. Business-key idempotent: at most one OPEN invitation per (tenant, user), so a duplicate is a clean 409.

emailbodystringconditionalyou choose
The invitee's email address, typed by the admin. Trimmed, valid email, max 320 chars. Stamps channel='email'. An existing identity is REUSED (a membership is added) rather than duplicated; otherwise a new one is created. Supply email OR phone, EXACTLY ONE — a body carrying both is rejected 422, because the delivery channel, and therefore the audit trail, would be ambiguous.
phonebodystringconditionalyou choose
The invitee's E.164 number (R3). Stamps channel='whatsapp' and creates a SHELL IDENTITY with the phone UNVERIFIED and no password — accepting is what proves the number and where the invitee chooses a password. Numbers are globally unique, so inviting one that already belongs to an identity reuses it, exactly as an email invite does. Supply email OR phone, exactly one.
displayNamebodystringoptionalyou choose
Optional human-readable name for the invited user, freely chosen by the admin. Trimmed, 1-200 chars when supplied.
Bearer token
→ POST https://api.kerja.team/v1/invitations
↩︎Response guide — what comes back & what each value means1 field

201 Created. Body is the standard { data } envelope exposing only the opaque invited user id; a Location: /v1/users/{userId} response header points to the (re)used identity. No Set-Cookie or Cache-Control; the invite token is delivered out-of-band and never appears in the response.

data.userIdUUIDalways
Opaque id of the user that was invited — either a freshly created identity or the existing one reused when the email or phone (the request carries exactly one) already names an identity. Matches the {userId} in the Location header; feed it into GET /v1/users/{userId} to inspect the new membership.
POST/v1/auth/invitations/acceptpublicAccept invitation (invitee)

Submit EITHER { token, password } (the link) OR { email, code, password } (the short code) — link and code are one challenge, so redeeming either kills the other, and exhausting the code's attempt cap consumes the row and takes the link with it. The link keeps its 7-day TTL while the code expires in 15 minutes: an invitee who lets the code lapse still has a working link, which is intentional. Acceptance now ENDS IN A SESSION (accessToken auto-captured, amr [pwd, email]) so the invitee is not asked to log in with a password they created seconds earlier — and invited users SKIP the separate email-verify step. password IS CONDITIONAL: required for a new identity, rejected 422 for one that already exists — the invite REUSES an existing account, and scenario 52 is the screen that finds out which before this call is made. Accepting also earns device trust on that browser, since the channel was just proven (scenario 23). An interrupted invitee finds their way back via scenario 6, which reports awaiting_invitation.

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

Public and unguarded (no Authorization REQUIRED) — it finalizes a tenant invitation by activating the invited membership (and, for a brand-new identity only, setting its password) and ENDS IN A SESSION, so the invitee is not asked to log in with a password they created seconds earlier. The message carries a 7-day link AND a 6-digit code; submit either. Works for an email invite (R4/R5, amr [pwd, email]) and a WhatsApp phone invite (R3, amr [pwd, sms] — which also sets phone_verified_at and login_mode='phone'). §139: for an identity that ALREADY EXISTS the caller must also PROVE it is that identity — the acceptToken from the invitationPending login answer, or that user's own gateway context (send the call with their bearer) — because the session this route ends in reaches switch-tenant, so the link or the 6 digits alone would be possession of that identity in EVERY tenant it belongs to, and any tenant admin can have the message sent to any address they can name. No proof, or proof belonging to a different user → 401 invitation_requires_signin (audited invitation_accept_unproven). A NEW invitee has no account to prove and presents nothing — that is why the route stays public. Walk-throughs: scenario 50 / 51 (new), 51 (existing account); a caller already signed in can accept in place at POST /v1/me/memberships/{tenantId}/accept (scenario 53).

tokenbodystringconditionalout-of-band
The single-use invitation LINK token (7-day TTL). The user does not pick this; they obtain it from the invitation message's accept link and paste it here. Supply this OR an identifier + code, never both — redeeming either half kills the other. 1–512 chars.
emailbodystringconditionalyou choose
The invited email address. Required when redeeming an EMAIL invitation by short code; omit it when submitting the link token or when the invite went out by phone.
phonebodystringconditionalyou choose
The invited E.164 number. Required when redeeming a WHATSAPP invitation (R3) by short code. The phone starts UNVERIFIED on a phone invite — accepting with the code is exactly what proves it, which is why the shell identity is created without a password.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the invitation message (trimmed, 1–16 chars), valid ~15 min while the link keeps its full 7 days — an invitee who lets the code lapse still has a working link, which is intentional. A code with no identifier, or email AND phone together, is 422. Attempt-capped: exhausting the cap consumes the row and takes the link with it — at which point there is nothing left to resend either (POST /v1/invitations/{userId}/resend answers 404 on a consumed row), so the admin must remove the invited membership and re-invite. Short of that, an invite that never arrived is re-delivered with fresh secrets by the resend endpoint (scenario 54), not by a second POST /v1/invitations.
passwordbodystringconditionalyou choose
CONDITIONAL, AND BOTH MISMATCHES ARE REFUSED. REQUIRED when the identity has no credential yet (a brand-new invitee freely chooses one here, 12-1024 chars); REJECTED 422 when the identity already has one — ask POST /v1/auth/invitations/describe first (scenario 52), whose needsPassword answers exactly this. Optional in the schema only because the service is the one that can see which case applies. Neither mismatch is silently ignored: a dropped password would leave the client believing it had set one, and a silently-missing one would leave an account with no way in. AN INVITATION IS NOT A PASSWORD RESET — for an existing identity accept writes nothing global (not the password, not the status, not the login mode, not the display name), because the invitation is tenant-scoped while the credential is global.
acceptTokenbodystringconditionalfrom a response
PROOF OF BEING THE INVITED IDENTITY (§139), 1–4096 chars. REQUIRED when the identity already has a credential (describe said needsPassword: false) UNLESS the request carries that same user's verified gateway context instead — i.e. you signed in and send the call with the bearer. Ignored for a brand-new identity. Where it comes from: POST /v1/auth/login as the invitee answers 200 { invitationPending: true, acceptToken, expiresIn, invitations[] } when their ONLY seats are unaccepted invitations — the tester shows it in the capture line; paste it here. It is purpose-scoped (token_use: accept_invitation): NOT interchangeable with the bootstrapToken of scenario 15, and useless anywhere an access token is expected. Invalid, expired or minted for another purpose → 401 invalid_credentials; absent for an existing identity with no matching gateway context, or naming a different user → 401 invitation_requires_signin. There is deliberately no field for NAMING the caller — .strict() refuses one, so the only way to be somebody is to present something the server signed.
displayNamebodystringoptionalyou choose
Optional display name the user types for their account. If supplied, it must be 1-255 chars. Ignored for an identity that already exists — accept writes nothing global for those.
clientIdbodystringoptionalyou choose
RESERVED for enhancement-5 and currently REJECTED with 422 while multi-app acceptance is absent — leave it out.
→ POST https://api.kerja.team/v1/auth/invitations/accept
↩︎Response guide — what comes back & what each value means6 fields

200 OK with the { data } envelope and Cache-Control: no-store. The 200 means the invitation (link OR short code — one row, either half) was valid and consumed, and the invited membership is now active. An identity that ALREADY EXISTS reaches this 200 only by also proving it is that identity (§139) — the acceptToken from the invitationPending login outcome, or its own verified gateway context; without it the answer is 401 invitation_requires_signin (scenario 52; signed-in callers can instead use POST /v1/me/memberships/{tenantId}/accept, scenario 53). WHAT ELSE IT WROTE DEPENDS ON WHO THE INVITEE IS. For a NEW identity: the password is set, the channel marked verified, the status set to active (user.upserted enqueued, invitation_accepted audited). For an identity that ALREADY EXISTS, accept writes NOTHING global — not the password, not the status, not the login mode, not the display name — because the invitation is tenant-scoped while the credential is global; a globally disabled identity therefore stays disabled. POST /v1/auth/invitations/describe (scenario 52) is what tells the client which branch it is in, via needsPassword, and sending the wrong shape is a 422 either way. SINCE ENHANCEMENT 4 IT ENDS IN A SESSION, so the invitee is not asked to log in with a password they created seconds earlier. The membership is in the INVITER's tenant and the invitee gets no tenant of their own — a regression test pins this. Accepting also earns device trust on that browser, since the channel was just proven.

data.accessTokenstringalways
The bearer token for the tenant surface, auto-captured by this tester. amr is [pwd, email] for an email invitation (R4/R5) and [pwd, sms] for a WhatsApp phone invitation (R3).
data.tokenTypestringalways
Always "Bearer".
data.expiresInnumberalways
Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600).
data.userobjectalways
The now-active invitee: id, email or phone, displayName, and the inviter's tenantId. A phone invitee additionally has phone_verified_at set and login_mode = phone, so they subsequently sign in at scenario 10.
Set-Cookie: refresh_tokencookiealways
The rotating refresh token, HttpOnly.
Set-Cookie: device_tokencookieconditional
Device trust earned by accepting — the channel was just proven on this browser, so the next login here can skip the new-device challenge (scenario 23 lists it).
PUT/v1/users/{userId}/roles/{role}adminGrant the invitee a role (admin)

The invitation itself grants no role beyond bare membership — assign one here. Idempotent; no body. A role outside the allow-list → 422.

Path parameters
ℹ︎Field guide — what each value means & where it comes from2 fields

Grants a role to a member; idempotent (granting an existing role is a no-op). No request body, no query params, and no caller-set headers — only two path params. Requires admin auth (Authorization + x-gateway-context are handled by the tester/gateway).

userIdpathUUIDrequiredfrom a response
The member to grant the role to. You do not type this freely; obtain it from a prior tenant member list or get response (the user's id field). Must be a valid UUID; a malformed value returns 422, and a user that is not a member of this tenant returns 404.
rolepathenumrequiredyou choose
The role name to grant; you pick one of the tenant's allow-listed roles (the same set returned by GET /v1/roles). An unknown role returns 422.
  • admin — Grants tenant administrator privileges (full admin access to tenant management endpoints).
  • member — Grants the standard member role for the tenant.
  • editor — Grants the editor role for the tenant.
Bearer token
→ PUT https://api.kerja.team/v1/users/{userId}/roles/editor
↩︎Response guide — what comes back & what each value means1 field

Returns 200 OK with the standard { data } envelope, where data is an empty object ({ "data": {} }) — granting a role is idempotent and confirms success without returning a body payload. No Set-Cookie or Cache-Control headers of note. Side effect: audits role_granted with the role name as the reason.

dataobjectalways
Empty object ({}) confirming the role grant succeeded. The role is now active for the member; no fields are returned — the client treats a 200 as confirmation and may re-fetch the member or roles list.
POST/v1/auth/loginpublicLog in (identifier + password)

Optional — step 2 already signed the invitee in; this is only needed if the client discarded that session.

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