switching clears the token & responses
No token
46

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.

  1. 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
  2. needsPassword: true → POST /v1/auth/invitations/accept WITH password (+ optional displayName) — a new identity, exactly as scenario 44 describes
  3. needsPassword: false → POST /v1/auth/invitations/accept WITHOUT password — an existing identity; only the MEMBERSHIP is activated
POST/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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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-band
The single-use invitation LINK token (7-day TTL), obtained from the invitation message's accept link — the same value accept takes. 1-512 chars. Supply this OR an identifier + code, never both (422). Mint one locally with the ⛓ invite-token helper.
emailbodystringconditionalyou choose
The invited email address, typed by the invitee. Required when describing an EMAIL invitation by short code — a code is not unique, so the identifier is what scopes the lookup. Omit it when submitting the link token or when the invite went out by phone. Trimmed, valid email, max 320 chars.
phonebodystringconditionalyou choose
The invited E.164 number, typed by the invitee. Required when describing a WHATSAPP invitation (R3) by short code. email and phone together is a 422.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the invitation message (~15 min, while the link keeps its full 7 days). Trimmed, 1-16 chars. A code with no identifier is a 422. A WRONG code costs an attempt here exactly as it does on accept — without that, describe would be a free oracle against the code accept carefully caps — and exhausting the cap consumes the row, taking the link with it.
POST https://api.kerja.team/v1/auth/invitations/describe
↩︎Response 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.

dataobjectalways
The invitation description.
data.validbooleanalways
Always true on a 200. It exists so the client has ONE shape to type against, not because false is reachable — an invalid invitation is a 401.
data.needsPasswordbooleanalways
THE FIELD THIS ENDPOINT EXISTS FOR — which branch of accept to render. Decided by a PROVEN CHANNEL (email_verified_at / phone_verified_at), not by 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 reading that column would lock every new email invitee out of the only screen that could give them a password.
  • trueThe 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.
  • falseThe 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 | nullalways
Which tenant is doing the inviting — the old accept screen named none. null when the tenant has no name recorded.
data.displayNamestring | nullalways
The account's EXISTING display name, if any. Non-null is itself a hint that the invitee already has an account. null for a fresh shell identity.
data.maskedIdentifierstring | nullalways
e.g. e*******@example.com or +62*********42 — enough to recognise your own account, not enough to disclose someone else's. THIS IS THE VALUE FOR THE CARD, the one the invitee reads. It cannot disagree with identifier: the mask is computed from it, and if they could disagree the invitee would read one address and be signed in as another.
data.identifierstring | nullalways
The REAL email or E.164 phone — THIS IS THE VALUE FOR THE SIGN-IN, because POST /v1/auth/login takes an identifier and a mask is not one. On the CODE path the invitee typed it a moment earlier so the client already has it; on the LINK path the client holds nothing but an opaque token and would otherwise have to ask for an address the invitation was already sent to. It discloses nothing new to anyone who can already reach this endpoint (the invitee owns the address, the admin typed it, the message carries it as the To: header) — but it does change what a LEAKED LINK is worth: a URL in a browser history, a referrer or a proxy log used to leak five characters of a number and now leaks the number, which is why the invitation link is a PII-bearing artifact for its full 7-day TTL.
POST/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.

⛓ 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).
POST/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.

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