Skip to main content

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

OperationIDMethod + PathRBACNotes
send-invitationsPOST /invitations/sendInviteUserbulk send by email
list-invitationsPOST /invitations/listListInvitationsWorkOS cursor pagination; optional email filter
revoke-invitationPOST /invitations/{id}/revokeRevokeInvitationid = WorkOS invitation id
accept-invitationPOST /invitations/accept— (public)body { token }; no auth (invitee isn't a member yet)
get-invite-linkPOST /invitations/link/getGetInviteLinkread-only: current link + its expiry
reset-invite-linkPOST /invitations/link/resetResetInviteLinkrotate: new link + fresh expiry (old stops working); pick validity
extend-invite-linkPOST /invitations/link/extendResetInviteLinkrenew: 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)
// 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).

// 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" }
  • reset rotates the token (new link, fresh expiry — the old link stops working); extend keeps 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 of membership/models/types.go (SendInvitationRequest, InvitationResult, InvitationSummary, SendInvitationsResponse, ListInvitationsRequest, RevokeInvitationRequest, AcceptInvitationRequest, Invitation, PaginatedInvitations, InviteLinkResponse).
  • invitation/module/ + routes.go ← DI + RegisterRoutes (accept registered public).
  • Shared, unchanged: the WorkOS usermanagement platform client.
  • membership shrinks to members-only (ListMembers, UpdateRole, RemoveMember).
  • The duplicated PaginationMaxLimit/DefaultLimit consts move to the shared package (space pass).

Notes

  • state is the exhaustive WorkOS set pending | accepted | expired | revoked (confirmed against the WorkOS API docs + Go SDK) — declared as a wire enum.
  • accept-invitation returns 204; the service validates the invitation is pending, then joins.
  • WorkOS's list order (asc/desc) param is not exposed (YAGNI — default order).