switching clears the token & responses
No token
52

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 50 describes
  3. 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
  4. 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
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.
  • true — 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 | 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 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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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-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 (trimmed, 1–16 chars), 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. A code with no identifier, or email AND phone together, is 422. 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 54), 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 52), 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.
acceptTokenbodystringconditionalfrom a response
PROOF OF BEING THE INVITED IDENTITY (§139), 1–4096 chars. REQUIRED when the identity already has a credential (describe said needsPassword: false) UNLESS the request carries that same user's verified gateway context instead — i.e. you signed in and send the call with the bearer. Ignored for a brand-new identity. Where it comes from: POST /v1/auth/login as the invitee answers 200 { invitationPending: true, acceptToken, expiresIn, invitations[] } when their ONLY seats are unaccepted invitations — the tester shows it in the capture line; paste it here. It is purpose-scoped (token_use: accept_invitation): NOT interchangeable with the bootstrapToken of scenario 15, and useless anywhere an access token is expected. Invalid, expired or minted for another purpose → 401 invalid_credentials; absent for an existing identity with no matching gateway context, or naming a different user → 401 invitation_requires_signin. There is deliberately no field for NAMING the caller — .strict() refuses one, so the only way to be somebody is to present something the server signed.
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 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.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 23 lists it).
POST/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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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 choose
The credential being authenticated: an email OR an E.164 phone number. The SHAPE decides the lookup, not a mode flag — anything matching ^\+[1-9]\d{6,14}$ is resolved by phone, anything else as an email — so a client never has to know which kind of account it is addressing. The credential you type must itself be VERIFIED (an email login needs a verified email, a phone login a verified phone). OPTIONAL ON A RECOGNISED DEVICE: when the body carries no identifier and a valid device_token cookie is presented, the account is resolved from the trusted device and its primary channel is used. That is what lets the “Welcome back” screen (scenario 8) ask for nothing but a password — it only ever had a MASKED identifier to show, and could not otherwise complete the login it had just offered. It grants nothing: the password is still required, and a missing / garbage / expired / revoked cookie with no identifier answers the SAME uniform 401 invalid_credentials as a wrong password (never a 422 “identifier is required”, which would itself confirm the cookie was rejected). An explicit identifier always takes precedence.
emailbodystringconditionalyou choose
DEPRECATED ALIAS for identifier, still accepted and normalised server-side so existing clients keep working. Send identifier in new integrations. Valid email, at most 320 chars.
clientIdbodystringoptionalyou choose
RESERVED and currently REJECTED with 422. The field exists so per-app tokens can land later without a route change — leave it out.
passwordbodystringrequiredyou choose
The account password the user types. 1-1024 chars. Verified against the stored credential; wrong password yields a uniform invalid_credentials 401.
→ POST https://api.kerja.team/v1/auth/login
↩︎Response 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.accessTokenstringconditional
Non-MFA branch only (MFA disabled). The signed JWT access token; the client sends it as Authorization: Bearer for subsequent calls.
data.tokenTypestringconditional
Non-MFA branch only. Token scheme to use in the Authorization header.
  • Bearer — Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditional
Token branch: access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600) — refresh before it elapses. No-organization / invitation-pending branches: the lifetime of the bootstrapToken / acceptToken instead — use it before it lapses, or log in again for a fresh one.
data.user.idstringconditional
Non-MFA branch only. The authenticated user's unique id.
data.user.emailstringconditional
Non-MFA branch only. The authenticated user's email.
data.user.displayNamestringconditional
Non-MFA branch only. Human-readable display name for the user, e.g. for UI greeting.
data.user.tenantIdstringconditional
Non-MFA branch only. The selected tenant for this session; if the user has multiple tenants, the default membership is chosen.
data.challengeTokenstringconditional
CHALLENGE branch only. The token that carries the ordered remaining plan and the accumulated amr — the server keeps no per-chain state. Copy it into POST /v1/auth/login/challenge/verify (or .../resend). Short-lived: LOGIN_CHALLENGE_TTL_SECONDS, default 300s.
data.challenge.typeenumconditional
CHALLENGE branch only. Which factor the first hop collects. A device hop always comes before an MFA hop, so the MFA hint is never leaked to an unrecognised machine.
  • device_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.hintstringconditional
CHALLENGE branch only. The masked destination the code went to (b••••@example.com, +••••••6789) — show it so the user knows which inbox or handset to check.
data.challenge.expiresInnumberconditional
CHALLENGE branch only. Seconds until this hop expires; when it lapses, start again at login.
data.remainingnumberconditional
CHALLENGE branch only. How many hops are still to clear, counting this one: 1 for a lone device or MFA challenge, 2 for the new-device-plus-MFA chain (LN3, scenario 13).
data.methodenumconditional
LEGACY MFA branch only. Which second factor to collect.
  • phone_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.phoneHintstringconditional
LEGACY MFA branch, phone_otp only. The masked destination the second-factor OTP will go to.
data.mfaRequiredbooleanconditional
MFA branch only (MFA enabled). Signals no token was issued and the caller must complete the MFA challenge via POST /v1/auth/mfa/verify (§3.8).
  • true — MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditional
MFA branch only. A single-purpose JWT (~5 min TTL), NOT an access token. The client copies it into the follow-up MFA verify (and OTP request) calls to complete login.
data.noOrganizationbooleanconditional
NO-ORGANIZATION branch only (§136, scenario 15): the credential was accepted but the caller belongs to no organization at all. No session, no refresh / device cookie, no access token.
  • true — Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditional
NO-ORGANIZATION branch only. A tenant-less, purpose-scoped token (token_use bootstrap) that buys exactly one call — POST /v1/auth/organizations/bootstrap. Refused wherever an access token is expected, and at accept. The tester shows it in the login card's capture line.
data.invitationPendingbooleanconditional
INVITATION-PENDING branch only (§139, scenario 52): the credential was accepted and EVERY membership row the caller holds is invited. No session. (A suspended seat anywhere lands on 403 instead of either branch.)
  • true — 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.acceptTokenstringconditional
INVITATION-PENDING branch only. Purpose-scoped (token_use accept_invitation) proof of identity for POST /v1/auth/invitations/accept — the identity proof an existing invitee needs there instead of a session. Not interchangeable with bootstrapToken. The tester shows it in the login card's capture line.
data.invitationsarrayconditional
INVITATION-PENDING branch only. EVERY pending invitation — the server does not choose which one the caller meant, so someone two tenants invited is shown both.
data.invitations[].tenantIdUUIDconditional
The inviting tenant.
data.invitations[].tenantNamestringconditional
The inviting organization's name, for the "you have been invited to …" screen.
POST/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.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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-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 (trimmed, 1–16 chars), 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. A code with no identifier, or email AND phone together, is 422. 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 54), 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 52), 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.
acceptTokenbodystringconditionalfrom a response
PROOF OF BEING THE INVITED IDENTITY (§139), 1–4096 chars. REQUIRED when the identity already has a credential (describe said needsPassword: false) UNLESS the request carries that same user's verified gateway context instead — i.e. you signed in and send the call with the bearer. Ignored for a brand-new identity. Where it comes from: POST /v1/auth/login as the invitee answers 200 { invitationPending: true, acceptToken, expiresIn, invitations[] } when their ONLY seats are unaccepted invitations — the tester shows it in the capture line; paste it here. It is purpose-scoped (token_use: accept_invitation): NOT interchangeable with the bootstrapToken of scenario 15, and useless anywhere an access token is expected. Invalid, expired or minted for another purpose → 401 invalid_credentials; absent for an existing identity with no matching gateway context, or naming a different user → 401 invitation_requires_signin. There is deliberately no field for NAMING the caller — .strict() refuses one, so the only way to be somebody is to present something the server signed.
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 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.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 23 lists it).