Resending an invitation that never arrived
The invitee reports “I never got it” — or got only the 15-minute code and let it lapse. The admin re-delivers THE SAME pending invitation with fresh secrets; nothing about the membership changes. Step 1½ of scenario 44 / 45, not a new invite: POST /v1/invitations answers 409 while an open invitation exists — even a lapsed one — so this is the only path that helps during the link's 7 days.
- POST /v1/invitations/{userId}/resend (admin) → 200 { userId, channel }; the outstanding open row is CONSUMED (old link and code both die) and a fresh 7-day link + fresh 6-digit code are minted on a new row, delivered on the invitation's ORIGINAL channel
- POST /v1/auth/invitations/accept (invitee) → exactly as in scenario 44 / 45 — the resend changes the secrets, never the acceptance contract
/v1/invitations/{userId}/resendadminResend a pending invitation (admin)No body. SUPERSEDE, NOT ACCUMULATE: the open row is consumed first, so at no point do two live links exist and uq_verification_tokens_open_invite stays satisfied — an invitee holding the old message finds both its link and its code dead, so tell them to use the newest one. Tenant-scoped: the same identity's open invitations in OTHER tenants are untouched. The delivery CHANNEL is fixed at invite time (an email invite resends by email, a WhatsApp invite by WhatsApp); to switch channels, remove the member (scenario 48 — which also revokes the invitation) and invite again on the other identifier. Works on a LAPSED invitation (the case where a plain re-invite also works) and on a LIVE one (the case where re-invite refuses 409). 404 covers both “not a member here” and “no open invitation” — a membership whose invite row was consumed, for instance by exhausting the code's attempt cap, has nothing left to resend, so remove and re-invite instead. 409 means the member ALREADY ACCEPTED (or was suspended since). Throttled per (tenant, member), counting every attempt and failing closed. The membership is not touched (still invited), so nothing is projected. Audited as invitation_resent with the transport as its reason, distinct from the original invitation_sent.
Field guide — what each value means & where it comes from1 field
Admin-only endpoint that RE-DELIVERS a pending invitation for the invitee who says "I never got it". No body at all — the only input is the {userId} path param, because everything else (which invitation, and over which channel) is already recorded on the open invite row. The outstanding row is CONSUMED first, so the previously sent link and code both die and no second live link ever exists; a fresh 7-day link + fresh 6-digit code are minted on a new row and delivered out-of-band on the invitation's ORIGINAL channel. Requires a tenant-admin Authorization token plus the gateway context (both handled by the tester). Throttled per (tenant, member), counting every attempt and failing closed.
userIdpathUUIDrequiredfrom a responsePOST https://api.kerja.team/v1/invitations/{userId}/resendResponse guide — what comes back & what each value means3 fields
200 OK with the standard { data } envelope. The fresh secrets go out-of-band only, exactly like the original invite — neither the new link token nor the new 6-digit code appears here, and the old pair is already dead by the time you read this. No Set-Cookie. The membership itself is untouched (still 'invited'), so nothing is projected; the delivery rides the same notification-lane outbox row (notification.invitation_sent) as the original invite, and the act is audited as invitation_resent with the transport as its reason — distinct from the original invitation_sent.
dataobjectalwaysdata.userIdUUIDalwaysdata.channelenumalwaysemail— The invitation was originally addressed to an email (R4/R5); the fresh 7-day link and 6-digit code were re-sent by email.whatsapp— The invitation was originally addressed to an E.164 phone (R3); the fresh link and code were re-sent over WhatsApp.
/v1/auth/invitations/acceptpublicAccept with the fresh secrets (invitee)Unchanged by the resend — the acceptance contract is the same one scenario 44 / 45 describe, and scenario 46 is still what tells you whether to send a password. Mint the value with the ⛓ helper AFTER the resend: the helper re-reads the live open row, so a value copied before step 1 is one of the two secrets the resend just killed.
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