Join links & requests
A join link is a door you can hand out when you don't know someone's email — or when you're standing in front of a room. Holding the link (or its short spoken code) grants exactly one thing: the right to ask to join the workspace. The ask lands in an approval queue; an authorized member decides; only approval mints membership. A leaked link is unauthorized noise, never unauthorized access.
This is deliberately separate from invitations: an invitation is pre-authorized and addressed ("invited by us"); a request arrives unvetted ("asked to join"). The two never share a table or a lifecycle.
How admission works
- Mint a door. An owner/admin creates a join link with a required expiry (≤ 90 days), a role ceiling (
viewerorbuilder— neverowner/admin/billing_admin), an optional 8-character spoken code, an optional email-domain filter, and an optional pending-queue cap. The raw secrets are returned once, at mint; the platform stores hashes only. - Someone asks. The visitor previews the workspace name pre-auth, signs in (magic link — their email is verified before the request exists), optionally says who they are, and lands in the queue. Re-clicking is idempotent; a wrong, revoked, or expired secret is one indistinguishable
404. - Someone decides. Approval mints the member and role assignment in a single guarded transaction — two concurrent approvals produce exactly one member — at a role at or below the link ceiling. Denial is terminal and can carry a reason. The requester is emailed either way.
A pending request is not a billable seat: the limit.members entitlement is checked at approval, and a full workspace answers 412 while the request survives — approvable after an upgrade.
Endpoints
| Method | Path | Permission | Description |
|---|---|---|---|
POST | /v1/organizations/{orgId}/join-links | organization.join_link.create | Mint a door (secrets returned once) |
GET | /v1/organizations/{orgId}/join-links | organization.join_link.list | List doors with live pending counts |
DELETE | /v1/organizations/{orgId}/join-links/{linkId} | organization.join_link.revoke | Revoke (removes nobody who already joined) |
PATCH | /v1/organizations/{orgId}/join-links/{linkId}/recipients | organization.settings.update | Per-link approval routing (verified before active) |
GET | /v1/join/{secret} | — (public) | Pre-auth preview: workspace name, label, ceiling |
POST | /v1/join/{secret} | authenticated | Ask to join (idempotent) |
GET | /v1/organizations/{orgId}/join-requests?status= | organization.join_request.list | The approval queue |
POST | /v1/organizations/{orgId}/join-requests/{id}/decide | organization.join_request.decide | Approve (role ≤ ceiling) or deny |
POST | /v1/me/join-requests/{id}/withdraw | authenticated (own) | Withdraw a pending ask |
GET / PUT / PATCH | /v1/organizations/{orgId}/join-settings | list / settings.update | Workspace routing default + the kill switch |
POST | /v1/join/recipients/confirm | — (token-proof) | Activate an approval-recipient address |
Minting a door is deliberately a different permission from inviting by email: an org can allow "invite a named colleague" without allowing "hand out a URL anyone can find".
Where approvals land
Notification routing resolves through a chain — first non-empty rung wins:
- The link's own recipients — up to 5 addresses set on the link.
- The workspace default — addresses set in join settings.
- Every active owner and admin — the derived fallback that always works.
Rungs 1 and 2 are inactive until verified: every listed address receives a confirmation email, and until one confirms, the chain falls through — a mistyped or unilaterally-chosen address can delay and disclose, but it can never silently swallow requests, and it can never decide. Approving always requires an authenticated session holding organization.join_request.decide; the emails carry no credential. The console queue (People & Access → Requests) is the source of truth regardless of routing.
Guessing, flooding, and the kill switches
- The link token is 256-bit — unguessable; its risk is leakage, priced in by the request-not-admit design.
- The spoken code is ~40 bits and speakable by design; redemption rides the tight
authrate-limit family, and failed code attempts trip a lockout that refuses code lookups for a cooldown, emitting a security-visible warning. Codes are for short-lived doors — hours to days, not the full 90. - Flood control: at most one live request per (link, person) — enforced by the database, not handler etiquette — plus the optional per-link pending cap.
- Kill switches:
JOIN_REQUESTS_ENABLED=falseremoves the entire surface at deploy scope (every route404s);PATCH /join-settings {"enabled": false}disables redemption for one workspace without touching links, queued requests, or routing — the seam SSO enforcement uses.
Stale pending requests are swept to expired after 30 days by the worker's daily cron.
What this feature deliberately does not do
- It never auto-admits. There is no configuration in which holding a secret yields membership.
- It doesn't change what members see. An approved
viewerhas ordinary viewer breadth — for demos to strangers, point links at a workspace built for demos. - Revoking a link removes nobody who already joined; membership removal stays on the members surface.