Accepting an invitation when you already have an account
One identifier is one identity across every tenant, so POST /v1/invitations REUSES an existing account rather than duplicating it. The invitee therefore arrives at the accept screen in one of two entirely different situations, and the screen has to know which BEFORE it renders — which is what POST /v1/auth/invitations/describe is for.
- POST /v1/auth/invitations/describe (invitee) → either { token } or { email | phone, code } — the same two representations accept takes → { valid, needsPassword, tenantName, displayName, maskedIdentifier, identifier }, WITHOUT consuming the invitation
- needsPassword: true → POST /v1/auth/invitations/accept WITH password (+ optional displayName) — a new identity, exactly as scenario 50 describes
- needsPassword: false → POST /v1/auth/login FIRST (step 1b) — that is where the proof comes from. A member of some other tenant gets ordinary tokens; someone whose ONLY membership is this invitation gets 200 { invitationPending, acceptToken } — no session, because there is nowhere yet to have one
- needsPassword: false → POST /v1/auth/invitations/accept WITHOUT password and WITH the proof from 1b — the acceptToken, or that user's gateway context (the bearer). Only the MEMBERSHIP is activated. Without proof: 401 invitation_requires_signin
/v1/auth/invitations/describepublicWhat is this invitation? (invitee, non-consuming)Takes the SAME two representations as accept — { token } (the link) or { email | phone, code } (the short code) — so the short-code invitee is not left on the wrong form while only the link invitee is fixed. Supplying both is 422, as on accept; password and displayName are rejected 422 (the schema is .strict(), so a copy-pasted accept body fails at the gate rather than being quietly ignored). NON-CONSUMING BY CONSTRUCTION, not by care: the same resolver accept uses, called with consume: false. But a WRONG short code still costs an attempt, and exhausting the cap consumes the row — taking the LINK with it, since they are the same row; a CORRECT code costs none, so describe-then-accept spends nothing extra. An invalid invitation is a uniform 401, never 200 { valid: false } — two different answers to the same question is how a uniform refusal stops being uniform. It is not a new enumeration oracle: accept already answers “is this token, or this code for this identifier, valid?” with the same 401 and the same cap.
Field guide — what each value means & where it comes from4 fields
Public endpoint (no Authorization needed) that says WHAT AN INVITATION IS without consuming it, so the accept screen knows which form to render before it renders one. It takes the SAME two representations as accept — the link { token }, or the short code together with the identifier it was sent to { email | phone, code } — so the short-code invitee is not left on the wrong form while only the link invitee is fixed. The schema is .strict() and accepts NOTHING else: a copy-pasted accept body carrying password or displayName is a 422 at the gate rather than a quietly ignored field. An invalid, expired or already-redeemed invitation is a uniform 401, never 200 { valid: false }. Non-consuming by construction (the same resolver accept uses, called with consume: false) — but a WRONG short code still costs an attempt, and exhausting the cap consumes the row and takes the LINK with it; a CORRECT code costs nothing, so describe-then-accept spends no extra attempt.
tokenbodystringconditionalout-of-bandemailbodystringconditionalyou choosephonebodystringconditionalyou choosecodebodystringconditionalout-of-bandPOST https://api.kerja.team/v1/auth/invitations/describeResponse guide — what comes back & what each value means7 fields
200 OK with the { data } envelope and Cache-Control: no-store — the body names a tenant and part of an identifier. A 200 means the invitation is valid AND STILL UNSPENT: describe reads through the same non-consuming lookup the operator-setup path uses, so the invitee can be asked what they are looking at before being shown a form. An invalid/expired/consumed invitation, a wrong code and an expired code are all the same uniform 401 — never 200 { valid: false }, because two different answers to the same question is how a uniform refusal stops being uniform. The token and the code are never echoed.
dataobjectalwaysdata.validbooleanalwaysdata.needsPasswordbooleanalwaystrue— The identity has no proven channel yet — call POST /v1/auth/invitations/accept WITH password (+ optional displayName), as in scenario 50. Omitting it there is 422. Someone who registered, never verified, and was then invited counts as new: they could not have logged in with that password, so nothing they were using is lost.false— The identity already exists and already has a credential — call accept WITHOUT password (sending one is 422). Only the MEMBERSHIP is activated; accept writes nothing global for this account — not the password, not the status, not the login mode, not the display name — and a globally disabled identity stays disabled.
data.tenantNamestring | nullalwaysdata.displayNamestring | nullalwaysdata.maskedIdentifierstring | nullalwaysdata.identifierstring | nullalways/v1/auth/invitations/acceptpublicAccept — needsPassword: true (a new identity)The branch scenario 50 walks: the identity has no proven channel yet, so it needs a password. Omitting one here is 422 — an account with no way in is not silently created. WHAT DECIDES needsPassword IS A PROVEN CHANNEL (email_verified_at / phone_verified_at), NOT password_hash: a brand-new EMAIL invitee is created with a RANDOM password hash because the database requires one on a row carrying an email, so that column is non-null for an account whose owner has never seen a password. Someone who registered, never verified, and is then invited counts as NEW — they could not have logged in with that password, so nothing they were using is lost.
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/auth/loginpublic1b — sign in first (existing identity only)Only when describe said needsPassword: false — an existing identity proves itself by signing in, and that proof is what accept then demands. A member of some other tenant gets ordinary TOKENS (auto-captured; accept can then use your gateway context). Someone whose ONLY membership is this invitation gets 200 { invitationPending: true, acceptToken, expiresIn, invitations: [{ tenantId, tenantName }] } — no session, no cookies, no access token, because there is nowhere yet to have one. invitations[] lists EVERY pending invitation; the server does not choose which one you meant. THIS HOP USED TO BE A DEAD END: an invitee previously REMOVED from the inviting tenant holds one membership row, invited, and login refused exactly that with 403 — so to accept they had to sign in, to sign in they had to hold a membership, and to hold one they had to accept. acceptToken (token_use: accept_invitation) is NOT interchangeable with the bootstrapToken of scenario 15, and a caller holding a mix of suspended and invited rows gets neither — the 403 stands.
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. A 200 is a BODY TO READ, not a status to trust — five shapes share it: tokens, a challenge chain (scenario 11–13), the legacy MFA challenge, { noOrganization, bootstrapToken } when the credential is good but you belong nowhere (scenario 15 — paste the token into POST /v1/auth/organizations/bootstrap), and { invitationPending, acceptToken, invitations[] } when your only seats are unaccepted invitations (scenario 52 — paste the token into POST /v1/auth/invitations/accept). The last two mint nothing session-shaped. 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 means23 fields
200 OK, { data } envelope, Cache-Control: no-store. A 200 here is a response you must READ, not a status you may trust: FIVE outcomes share it, and the login response itself is the only way to learn which 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; (4) NO ORGANIZATION { noOrganization: true, bootstrapToken, expiresIn } — the credential is good but the caller belongs nowhere (§136, scenario 15), continue at POST /v1/auth/organizations/bootstrap; (5) INVITATION PENDING { invitationPending: true, acceptToken, expiresIn, invitations[] } — every membership the caller holds is an unaccepted invitation (§139, scenario 52), continue at POST /v1/auth/invitations/accept. Branches 4 and 5 mint NOTHING session-shaped: no session row, no refresh or device cookie, no access token, no tenant_id claim. Only the token branch sets Set-Cookie: refresh_token. A caller whose only membership is suspended (or a mix of suspended and invited) is offered neither sessionless branch and still gets 403.
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.mfaTokenstringconditionaldata.noOrganizationbooleanconditionaltrue— Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditionaldata.invitationPendingbooleanconditionaltrue— Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditionaldata.invitationsarrayconditionaldata.invitations[].tenantIdUUIDconditionaldata.invitations[].tenantNamestringconditional/v1/auth/invitations/acceptpublicAccept — needsPassword: false (an existing identity)Submit WITHOUT password and WITH PROOF OF BEING THAT IDENTITY (§139): the acceptToken from the invitationPending login at 1b, or — if 1b signed you in — drop acceptToken and send the request with your bearer so it carries your gateway context. Anything else is 401 invitation_requires_signin (audited invitation_accept_unproven): this route ends in a session, a session reaches switch-tenant, so possession of 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. There is deliberately no field for naming the caller. Submit WITHOUT password. Sending one for an account that already has a credential is 422 — neither mismatch is silently ignored, because a dropped password would leave the client believing it had set one. AN INVITATION IS NOT A PASSWORD RESET: for an existing identity this call writes NOTHING global — not the password, not the status, not the login mode, not the display name — only the membership is activated. Before the split, accept hashed the supplied password and forced status = active for everyone, so one tenant admin could cause any address they could name to receive what amounted to a reset (7-day TTL, no knowledge of the old password) and could bring a globally DISABLED identity back to active. Membership activation is the only lifecycle change an invitation is entitled to make. The invitee still ends SIGNED IN and still earns device trust on that browser.
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