Skip to main content

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

  1. Mint a door. An owner/admin creates a join link with a required expiry (≤ 90 days), a role ceiling (viewer or builder — never owner/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.
  2. 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.
  3. 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

MethodPathPermissionDescription
POST/v1/organizations/{orgId}/join-linksorganization.join_link.createMint a door (secrets returned once)
GET/v1/organizations/{orgId}/join-linksorganization.join_link.listList doors with live pending counts
DELETE/v1/organizations/{orgId}/join-links/{linkId}organization.join_link.revokeRevoke (removes nobody who already joined)
PATCH/v1/organizations/{orgId}/join-links/{linkId}/recipientsorganization.settings.updatePer-link approval routing (verified before active)
GET/v1/join/{secret}— (public)Pre-auth preview: workspace name, label, ceiling
POST/v1/join/{secret}authenticatedAsk to join (idempotent)
GET/v1/organizations/{orgId}/join-requests?status=organization.join_request.listThe approval queue
POST/v1/organizations/{orgId}/join-requests/{id}/decideorganization.join_request.decideApprove (role ≤ ceiling) or deny
POST/v1/me/join-requests/{id}/withdrawauthenticated (own)Withdraw a pending ask
GET / PUT / PATCH/v1/organizations/{orgId}/join-settingslist / settings.updateWorkspace 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:

  1. The link's own recipients — up to 5 addresses set on the link.
  2. The workspace default — addresses set in join settings.
  3. 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 auth rate-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=false removes the entire surface at deploy scope (every route 404s); 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 viewer has 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.