switching clears the token & responses
No token
44

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 35).

  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 47) 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 48) 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 already exists. 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 46 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 22). 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 from7 fields

Public endpoint (no Authorization needed) that finalizes a tenant invitation by setting the invited user's password and activating their membership — and, since Enhancement 4, 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').

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, 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. 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 47), 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 46), 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.
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. The 200 means the invitation (link OR short code — one row, either half) was valid and consumed, and the invited membership is now active. 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 46) 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 22 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.
  • adminGrants tenant administrator privileges (full admin access to tenant management endpoints).
  • memberGrants the standard member role for the tenant.
  • editorGrants 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. 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.