switching clears the token & responses
No token
45

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.

  1. 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
  2. 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])
  3. PUT /v1/users/{userId}/roles/{role} (admin) → assign roles as for an email invite
POST/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.

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

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

Idempotent; no body. Same allow-list as an email invite.

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/member
↩︎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.