Inviting a teammate by phone (WhatsApp)
The same flow as scenario 44, 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 31). Everything else — one open invite per (tenant, user), link/code interchangeability, session on accept, no tenant of their own — is identical to scenario 44.
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)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