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 44 describes
- needsPassword: false → POST /v1/auth/invitations/accept WITHOUT password — an existing identity; only the MEMBERSHIP is activated
/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 44. 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 44 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 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/auth/invitations/acceptpublicAccept — needsPassword: false (an existing identity)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 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