Skip to content

CORD-05: Invites

An invite is how members are handed the keys that make them members. A shareable link’s keys live in an encrypted bundle on relays; the link itself carries only a public locator and an off-network unlock token, never the keys. Anyone the link reaches can join, nobody who lacks it can, and a link can be revoked without rerolling access keys. When the invitee is a known npub, the URL machinery drops away: the bundle giftwraps straight to them as a Direct Invite (§6).


An invite delivers a bundle to relays, encrypted by an off-network URL token:

CommunityInvite {
community_id, owner, owner_salt, // owner proof: community_id == sha256(owner || salt)
community_root, root_epoch, // the base access key + its current epoch
control_pk, // the Control Plane's signer pubkey at that epoch (CORD-02 §5): subscribe, verify, read — never write.
// Absent = a legacy, pre-split Community: fold Control at the legacy address instead (CORD-06 §3)
channels: [{ id, key, epoch, name }], // the granted Channels
relays, // where the Community publishes
name, icon, // preview, so a parked invite can render
expires_at, // optional, unix ms: past it, the preview still renders, joining refuses
creator_npub, label // optional attribution, echoed in the joiner's Join (below)
}

The inviter’s own identity is irrelevant to trust: the community_id self-certifies the owner (CORD-02), so a bundle can’t smuggle a false owner or a fake key for a real Community. A bundle whose owner and owner_salt fail to reproduce the community_id is refused. One field is taken on trust that the others are not: control_pk derives from a secret the joiner will never hold (CORD-02 §2), so nothing in the bundle can prove it. A wrong one is eclipse-class self-harm by the inviter — the joiner reads a stale or empty Control Plane, the same trust class as a hostile relays list — never forged authority, because every edition still verifies against the owner-rooted Roster (CORD-04), and a later base rotation, verified against that Roster, re-delivers the true key (CORD-06).

A bundle is attacker-crafted input reached by following a link, so a client MUST bound it before allocating: reject a bundle carrying more than a sane channel count (Vector’s ceiling is 256), and truncate relays to the Community’s cap (CORD-02 §6). Absent bounds, a hostile link is an unbounded-allocation and connect-storm vector.

A bundle may name its creator and a human label for the link (“Reddit”, “Conf 2026”). An accepting joiner echoes both in their Guestbook Join (CORD-02 §5), which makes per-link usage counters possible — all inside the token-encrypted bundle, visible to link-holders alone.

An invite bundle is passive: it waits on relays to be fetched, so a user only encounters one by following a link, and even then the client merely pulls it to preview the Community. Nothing joins, subscribes, or announces presence until the user explicitly accepts.

An invite is a URL in two parts, a public locator in the path and a secret in the #fragment:

$BASE/invite/<naddr>#<fragment>

The naddr is a bare NIP-19 addressable-event pointer, (kind 33301, link_signer, ""), naming where the bundle lives. It’s a locator, not a secret (the Registry publishes locators to every member anyway, §5), so it rides in the path in the open. The fragment carries a random 16-byte unlock token (plus the bootstrap relays, §3); a fragment is never sent to any server, so the base domain and the relays see where a bundle sits but can never open one. The base is interchangeable: the same naddr and fragment open on vectorapp.io/invite/…, on Soapbox’s Armada web base, or on any deeplink redirect domain — only the naddr and fragment are protocol, and any client recognizing an invite must respect them verbatim. The token derives exactly one thing:

bundle_key = hkdf(token, "concord/invite-key") // decrypts the bundle

Minting a link mints a fresh link signer, a keypair used for nothing else, its secret kept in the creator’s Invite List (§4). The bundle is posted as an addressable event authored by link_signer with an empty d identifier — the per-link pubkey alone makes the coordinate unique, so the naddr carries no identifier bytes and no relay entries (relays travel compactly in the fragment, §3), keeping it as short as an naddr gets. Fetchers locate the bundle by the full addressable coordinate (kind, author, d), which makes the guard airtight in both directions: a squatter can’t squat (a different author is a different coordinate), and a link-holder can’t replace or tombstone the bundle either, because posting to the coordinate requires the link_signer secret, held only by the creator. The worst a malicious invitee can do is join, and the self-certifying community_id (§1) already ensures even a compromised creator can’t smuggle a false owner. On the wire, this is one of the few Concord events relays see bare (CORD-02 Appendix B):

{ "kind": 33301, "pubkey": "<link_signer's pubkey>",
"content": "<nip44_encrypt(bundle_key, the §1 bundle as JSON)>",
"tags": [ ["d", ""], ["vsk", "6"] ] } // vsk 9: the revocation tombstone (below)

Because the coordinate is stable, the link itself never changes: the creator re-posting under it refreshes the bundle (fresh keys behind the same URL, e.g. after a Rekey), so a link shared once survives every rotation. Retiring a link works the same way: the coordinate is re-posted as a revocation tombstone (a sub-kind flag, still signer-signed), replacing the bundle so a fetcher finds the grave instead of keys. Unlike a relay deletion (best-effort, ignorable), the tombstone is exactly as durable as the bundle it replaced. Retiring the last live link flips a Community back to Private (a Refounding, CORD-06).

Clients can check whether a link is still live by looking for a tombstone at the addressable coordinate.

An invite must include the Community’s relays for immediate reachability, but full URLs inflate the link past what length-restricted platforms accept. So Concord defines a small, trusted relay dictionary every client knows, and a community using these relays references each by a single byte.

The stock set (four primaries, two Vector and two Soapbox) is selected by one flag, so the common invite carries zero additional relay bytes:

1 = wss://jskitty.com/nostr (Vector)
2 = wss://asia.vectorapp.io/nostr (Vector)
3 = wss://relay.ditto.pub (Soapbox)
4 = wss://relay.dreamith.to (Soapbox)

The fragment encodes [version][flags][relays?][token:16] as base64url with no padding. version is a format byte that also selects the dictionary generation; it’s 4 (a client MAY reject any lower value as a legacy link rather than decode it against the wrong dictionary). The flags bit selects the stock set, in which case zero relay bytes follow. Otherwise a count byte precedes count entries, each a leading byte:

1..=254 a dictionary id (the relay is one byte, no literal)
0 a wss-implied literal: [len][host bytes], "wss://" re-prepended on decode
255 a verbatim literal: [len][full URL bytes], for ws:// or exotic schemes

The fragment carries at most 3 bootstrap relays: it only needs to find the bundle, which then carries the Community’s authoritative relay set (CORD-02 §6).

The dictionary is a default, not a requirement: a client or user can skip the primaries and encode their own relays inline (the escape bytes above). The stock set lets a non-technical user create and join a Community with zero configuration; anyone wanting full control keeps it.

The dictionary is versioned so it can grow without breaking older links, and Vector and Soapbox ship it identically — an invite minted by either client opens in the other.

A creator’s minted links sync across their devices (and their clients — two apps can serve one npub) as the Invite List: the kind 13303 replaceable (CORD-02, Appendix B), NIP-44-encrypted to self, the creator’s private bookkeeping:

{ "entries": [
{ "token": "<hex>", // the link's unlock secret AND its merge key
"signer_sk": "<hex>", // the link_signer secret: refreshing or retiring the bundle needs it (§2)
"community_id": "<hex>",
"url": "<the shareable link>",
"label": "Reddit", // optional, the link's human name
"created_at": 1719800000,
"expires_at": 1722400000 } ], // optional
"tombstones": [ { "token": "<hex>", "community_id": "<hex>" } ] }

Two copies merge without coordination: the token is the merge key, an entry is immutable once minted, tombstones union, and a tombstone always beats an entry — terminally, so a stale device can never resurrect a revoked link. Because two clients can share this one document, the round-trip discipline applies (CORD-02 §6): preserve what you don’t understand.

The Invite List is the creator’s private bookkeeping; the Registry is its member-facing shadow. Every creator of live public links also publishes a Control Plane entity (vsk 8, CORD-04) listing them into the Community, at a coordinate bound to the creator (CORD-02, A.6), so each creator owns exactly their own list and nobody can forge entries into anyone else’s. Its content is the live links’ coordinates only:

["<link_signer pubkey hex>", "<link_signer pubkey hex>"] // coordinate authors (kind 33301, d empty, §2), never tokens, URLs, or signing secrets

Members fold every creator’s Registry (honored only while its author holds CREATE_INVITE) into one aggregate active-set, and that set is the Public/Private source of truth: non-empty means a live link exists and the Community is Public, empty means Private. Retiring the last live link (§2) empties the set and triggers the Refounding (CORD-06). A Registry edit accompanies every mint and every retire, and because it carries locators rather than secrets, members can see that links exist without being able to use one.

Everything above is armor for a hostile journey: a link rides plaintext channels, so its keys hide in a relay-side bundle behind an off-network token, revocable because anyone along the way may have copied the URL. Nostr already has an encrypted, authenticated lane to a specific npub, so a Direct Invite drops the machinery and hands over the §1 bundle itself: a standard NIP-59 giftwrap (ephemeral wrap author, the recipient in the p tag, a kind 13 seal — not the reversed stream wrap of CORD-01) whose rumor is kind 3313 (CORD-02, Appendix B), its content the CommunityInvite JSON. No coordinate, no token, nothing to fetch:

{ "kind": 3313, "pubkey": "<inviter's real pubkey>",
"content": "<the §1 CommunityInvite, as JSON>",
"tags": [] }

The seal’s verified npub proves who invited them, and the bundle validates exactly as a fetched one: the community_id self-certifies the owner, the §1 bounds apply, expires_at refuses a late join. Accepting means keeping the keys and publishing a Join; declining means discarding them. Nothing — no relay connection, no icon fetch, no Join — happens before the user decides.

The wrap carries what no Concord stream event may, an identifying outer tag, a k naming the rumor’s kind:

{ "kind": 1059, "pubkey": "<ephemeral, single-use>",
"content": "<nip44_encrypt(ephemeral↔recipient, the kind 13 seal)>",
"tags": [ ["p", "<recipient>"], ["k", "3313"] ] }

The k tag makes invites indexed: a recipient looks up exactly their invites, {"kinds":[1059], "#p":["<me>"], "#k":["3313"]}, instead of decrypting everything ever p-tagged at them (NIP-17’s cost), so an invite surfaces even in a client that syncs no other giftwrap traffic. The unmasking is deliberate and narrow: an observer learns that someone invited this npub to some community at roughly some time (NIP-59’s timestamp tweak applies), never which community, who sent it, or whether it was accepted. It never weakens the stream camouflage, because a Direct Invite is person-addressed NIP-59, not stream traffic (CORD-02, Appendix B). The tag is unsigned relay-visible bytes, a hint and never authority: an invite is whatever unwraps to a kind 3313 rumor, a wrap whose tag lies wastes its own indexability, and a client decrypting its general giftwrap inbox honors an untagged invite all the same.

A Direct Invite is a key handoff, not a standing door:

  • It can’t be revoked: the recipient holds the keys the moment it lands. Regretting one is what Rekeys are for (CORD-06), the same as regretting any member.
  • It grants exactly what it carries: no refresh, no tombstone. expires_at bounds its shelf life, and NIP-40 lets relays prune it.
  • It appears in no Registry and never flips the Community Public — which is precisely what makes it the Private Community’s way to grow: membership by personal handoff, one npub at a time, no live link ever existing (§5).
  • No permission gates it, because none could: any keyholder can whisper keys, the ungateable act §2 already accepted as the floor.

Delivery targets the recipient’s giftwrap inbox: the relays in their kind 10050 DM-relay list (NIP-17) when one exists, their NIP-65 read relays otherwise.