switching clears the token & responses
No token
47

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.

  1. 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
  2. POST /v1/auth/invitations/accept (invitee) → exactly as in scenario 44 / 45 — the resend changes the secrets, never the acceptance contract
POST/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.

Path parameters
ℹ︎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 response
The INVITED member to re-deliver to. Copy it from the 201 body of POST /v1/invitations (or its Location header), or from the members listing GET /v1/users (scenario 48). Not a UUID → 422. A user who is not a member of the caller's tenant, OR a member with no open invitation, is the SAME 404 — a foreign-tenant id is deliberately indistinguishable from an unknown one, so membership is not leaked. A member whose invite row was already consumed (for instance by exhausting the code's attempt cap) has nothing left to resend and also answers 404: remove the member and invite again. 409 means the membership is no longer 'invited' — they already accepted, or were suspended since — so there is nothing pending to deliver.
Bearer token
POST https://api.kerja.team/v1/invitations/{userId}/resend
↩︎Response 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.

dataobjectalways
The resend result.
data.userIdUUIDalways
Echoes the {userId} path param — the invited member the fresh invitation was re-delivered to. Confirms the correct member was targeted.
data.channelenumalways
Where the fresh link and code actually went. FIXED AT INVITE TIME and not selectable here: an email invite resends by email, a WhatsApp invite by WhatsApp. Read it to tell the invitee which inbox to look in. To switch channels you must remove the member (which also revokes the invitation) and invite again on the other identifier.
  • emailThe invitation was originally addressed to an email (R4/R5); the fresh 7-day link and 6-digit code were re-sent by email.
  • whatsappThe invitation was originally addressed to an E.164 phone (R3); the fresh link and code were re-sent over WhatsApp.
POST/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.

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