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).
- 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
- POST /v1/auth/invitations/accept (invitee) → either { token, password } or { email, code, password } → membership activated, email auto-verified, accessToken + refresh cookie (amr: [pwd, email])
- PUT /v1/users/{userId}/roles/{role} (admin) → assign any role beyond bare membership — the invite itself grants none
/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.
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 choosephonebodystringconditionalyou choosedisplayNamebodystringoptionalyou choosePOST https://api.kerja.team/v1/invitationsResponse 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/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.
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-bandemailbodystringconditionalyou choosephonebodystringconditionalyou choosecodebodystringconditionalout-of-bandpasswordbodystringconditionalyou choosedisplayNamebodystringoptionalyou chooseclientIdbodystringoptionalyou choosePOST https://api.kerja.team/v1/auth/invitations/acceptResponse 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.accessTokenstringalwaysdata.tokenTypestringalwaysdata.expiresInnumberalwaysdata.userobjectalwaysSet-Cookie: refresh_tokencookiealwaysSet-Cookie: device_tokencookieconditional/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.
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 responserolepathenumrequiredyou chooseadmin— 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.
PUT https://api.kerja.team/v1/users/{userId}/roles/editorResponse 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/v1/auth/loginpublicLog in (identifier + password)Optional — step 2 already signed the invitee in; this is only needed if the client discarded that session.
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 chooseemailbodystringconditionalyou chooseclientIdbodystringoptionalyou choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/loginResponse 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.accessTokenstringconditionaldata.tokenTypestringconditionalBearer— Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditionaldata.user.idstringconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdstringconditionaldata.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_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.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.methodenumconditionalphone_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.phoneHintstringconditionaldata.mfaRequiredbooleanconditionaltrue— MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional