Resource: Invitations (+ invite-link)
Tags: Invitations · Module: invitation (extracted from membership/organization)
Invite a person (by email) to join the organization as a member: an admin sends an invitation → the
invitee accepts → they become a member. Backed entirely by WorkOS (no local table). The org-level
reusable invite-link (same goal, self-serve) lives under the same tag/module. Extracted from the
membership handler and the /organizations/* routes into a dedicated invitation module.
Scope: this resource is org-member invitations only. A future guest / temporary-access invite is
expected to be a separate resource (e.g. /guest-passes) — different backing (ephemeral space-scoped
access, not a WorkOS org membership), lifecycle, and fields — not a type discriminator on this one. The
resource noun is plural (invitations); the verbs are the trailing segments (/send, /{id}/revoke).
Grounded: membership/service/{sendinvite,listinvites,revokeinvite,acceptinvitation,invitelink}.go;
states come from WorkOS usermanagement; AcceptInvitation returns no body; send/list take org +
inviter from the auth context, not the client.
Operations
| OperationID | Method + Path | RBAC | Notes |
|---|---|---|---|
send-invitations | POST /invitations/send | InviteUser | bulk send by email |
list-invitations | POST /invitations/list | ListInvitations | WorkOS cursor pagination; optional email filter |
revoke-invitation | POST /invitations/{id}/revoke | RevokeInvitation | id = WorkOS invitation id |
accept-invitation | POST /invitations/accept | — (public) | body { token }; no auth (invitee isn't a member yet) |
get-invite-link | POST /invitations/link/get | GetInviteLink | read-only: current link + its expiry |
reset-invite-link | POST /invitations/link/reset | ResetInviteLink | rotate: new link + fresh expiry (old stops working); pick validity |
extend-invite-link | POST /invitations/link/extend | ResetInviteLink | renew: same link, fresh expiry; pick validity |
These move off the membership handler / /organizations/* paths. accept-invitation (was the public
/organizations/accept-invitation) declares no Security, so the auth middleware self-skips it.
Identifier — WorkOS invitation id
Invitations have no local row; they live in WorkOS, so the WorkOS invitation id is the wire id. This is
the one sanctioned exception to "no vendor ids on the wire" (C2) — the resource is a WorkOS resource, so
there is no internal id to prefer.
DTOs
Invitation
{
"id": "string", // WorkOS invitation id
"email": "string",
"state": "pending | accepted | expired | revoked", // C6 — exhaustive WorkOS set
"expiresAt": "2026-06-20T00:00:00Z", // C3 — RFC3339
"acceptedAt": "string | null", // C3 — set when state → accepted
"revokedAt": "string | null", // C3 — set when state → revoked
"createdAt": "2026-06-14T10:00:00Z" // C3
}
// Dropped (WorkOS provides them, but unused by the FE / unsafe / redundant):
// token, acceptInvitationUrl — secrets that grant acceptance; reach the invitee by email
// inviterUserId — FE doesn't surface "invited by"; add later (mapped to internal id) if needed
// organizationId — always the caller's org (from auth)
// updatedAt — redundant with acceptedAt/revokedAt
send-invitations
// Request — org + inviter come from auth, not the client
{ "emails": ["string"] } // 1..100, each a valid email
// Response — C11 batch envelope (partial-tolerant); correlation key = email; in-body `code` dropped (C7)
{
"results": [ { "key": "string", "ok": true, "error": null } ],
// key = email; on failure: { "key": "string", "ok": false, "error": { /* Problem Details, C7 */ } }
"summary": { "total": 10, "successful": 9, "failed": 1 }
}
list-invitations (cursor pagination — the C4 exception)
// Request — organizationId removed (derived from auth; client can't list other orgs' invitations)
{
"email?": "string", // C8 — optional filter
"limit": 20, // 1..100
"before?": "cursor", // C8 — optional, page backward
"after?": "cursor" // C8 — optional, page forward
}
// Response — cursor envelope (C4 documented exception: WorkOS is cursor-based, not offset)
{
"data": [ /* Invitation */ ],
"page": { "before": "cursor | null", "after": "cursor | null" }
}
revoke-invitation ({id} in path)
// Request body — empty {}
// Response — the updated Invitation (state = revoked)
{ /* Invitation */ }
accept-invitation (public)
// Request
{ "token": "string" } // the token from the invitation email
// Response — 204 No Content (service validates the WorkOS invitation is pending, then joins)
get-invite-link
// Request body — empty {} (org from auth)
// Response
{
"inviteLink": "string", // {baseURL}/join/{slug}/{inviteId} — see below
"expiresAt": "2026-07-14T00:00:00Z" // C3 — may be in the past (= expired); client derives "time left"/"expired"
}
inviteLink format is {baseURL}/join/{slug}/{inviteId} — matching the FE member-invite route
/join/$orgSlug/$token. The slug is the org's handle (defined on the Organization —
see organizations.md); inviteId is the org's reusable invite token.
get is read-only. It lazily generates a link only if the org has none yet (default 30-day
validity); it does not auto-rotate an expired link — renewal is an explicit admin action (extend or
reset). This matches every product surveyed (Slack/Discord/GitHub/Atlassian require an explicit renew;
none auto-regenerate, and rotation is an audited action).
reset-invite-link / extend-invite-link
// Request — optional; defaults to 30 days
{ "validity?": "30d" } // C8 — one of: 1d | 7d | 30d | 90d (no "never" — finite by policy)
// Response
{ "inviteLink": "string", "expiresAt": "2026-07-14T00:00:00Z" }
resetrotates the token (new link, fresh expiry — the old link stops working);extendkeeps the same link with a fresh expiry (existing shared links keep working).- Default validity 30 days (the Slack/Atlassian norm for reusable org links). "Never" is intentionally unavailable — finite lifetimes are the security best practice (OWASP, GitLab token-lifetime guidance).
Backend deltas: org gains invite_expires_at; get surfaces it (lazy-init only when missing);
reset/extend set it from validity; and critically the consumption path (handleInviteToken) must
reject an expired token (Problem Details type: .../invite-link-expired, HTTP 410) — today it never expires.
Module extraction (backend)
New internal/features/invitation/ (standard layout), landing with this resource:
invitation/api/←membership/api/{sendinvite,listinvites,revokeinvite,acceptinvitation,getinvitelink,resetinvitelink}.go.invitation/service/←membership/service/{sendinvite,listinvites,revokeinvite,acceptinvitation,invitelink}.go.invitation/models/← move the invitation wire models out ofmembership/models/types.go(SendInvitationRequest,InvitationResult,InvitationSummary,SendInvitationsResponse,ListInvitationsRequest,RevokeInvitationRequest,AcceptInvitationRequest,Invitation,PaginatedInvitations,InviteLinkResponse).invitation/module/+routes.go← DI +RegisterRoutes(acceptregistered public).- Shared, unchanged: the WorkOS
usermanagementplatform client. membershipshrinks to members-only (ListMembers,UpdateRole,RemoveMember).- The duplicated
PaginationMaxLimit/DefaultLimitconsts move to the shared package (space pass).
Notes
stateis the exhaustive WorkOS setpending | accepted | expired | revoked(confirmed against the WorkOS API docs + Go SDK) — declared as a wire enum.accept-invitationreturns 204; the service validates the invitation is pending, then joins.- WorkOS's list
order(asc/desc) param is not exposed (YAGNI — default order).