Inviting a teammate by phone (WhatsApp)
The same flow as scenario 50, addressed by PHONE NUMBER and delivered over WhatsApp — for teammates who have no work email. This is R3. Routed by which identifier the admin supplies; the two are mutually exclusive.
- POST /v1/invitations (admin) with { phone } → 201 { userId }; a SHELL identity with the phone UNVERIFIED, an invited membership, and an invitation stamped channel = whatsapp carrying a link AND a code
- POST /v1/auth/invitations/accept (invitee) with { phone, code, password } (or the link) → membership activated, phone_verified_at set, login_mode = phone, accessToken + refresh cookie (amr: [pwd, sms])
- PUT /v1/users/{userId}/roles/{role} (admin) → assign roles as for an email invite
/v1/invitationsadminInvite a user by phone (admin)phone is E.164. Creates a shell identity with the phone UNVERIFIED and no password — ACCEPTING is what proves the number and where the password is chosen. Exactly one identifier: a body with both email and phone → 422. channel = whatsapp is what distinguishes R3 from R4/R5 afterwards; both produce the same invite purpose, and the invitation_sent audit event records the transport as its reason. Phone numbers are globally unique — inviting a number that already belongs to an identity REUSES it, exactly as an email invite does.
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 a phone invitation (invitee)Submit { phone, code, password } (or the link form { token, password }). Activates the membership, sets phone_verified_at, sets login_mode to phone, and returns an accessToken + refresh cookie (amr: [pwd, sms]) — auto-captured. The invitee therefore ends up PHONE-PRIMARY: they subsequently sign in at scenario 10 and any new-device challenge reaches them by phone; they can switch channel later once they also hold a verified email (scenario 33). Everything else — one open invite per (tenant, user), link/code interchangeability, session on accept, no tenant of their own — is identical to scenario 50.
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-bandemailbodystringconditionalyou choosephonebodystringconditionalyou choosecodebodystringconditionalout-of-bandpasswordbodystringconditionalyou chooseacceptTokenbodystringconditionalfrom a responsedisplayNamebodystringoptionalyou 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 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.accessTokenstringalwaysdata.tokenTypestringalwaysdata.expiresInnumberalwaysdata.userobjectalwaysSet-Cookie: refresh_tokencookiealwaysSet-Cookie: device_tokencookieconditional/v1/users/{userId}/roles/{role}adminGrant the invitee a role (admin)Idempotent; no body. Same allow-list as an email invite.
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/memberResponse 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