Menu
NIP-nip-65 Final

NIP-65: Relay List Metadata

Publish your preferred relays for reading and writing. Enable efficient discovery and routing.

Type Nostr Implementation Possibility
Number nip-65
Status Final
Original https://github.com/nostr-protocol/nips/blob/master/65.md

NIP-65: Relay List Metadata

Status: Final

NIP-65 defines how users publish their preferred relays. This enables efficient content discovery—instead of querying everywhere, clients can find where specific users publish.

Event Format

Kind 10002 event with relay URLs in tags:

{
  "kind": 10002,
  "pubkey": "your-pubkey",
  "created_at": 1234567890,
  "tags": [
    ["r", "wss://relay.damus.io", "read"],
    ["r", "wss://nos.lol", "write"],
    ["r", "wss://relay.nostr.band"]
  ],
  "content": "",
  "sig": "..."
}

Tag Format

["r", <relay-url>, <marker>?]
MarkerMeaning
readClient reads from this relay
writeClient writes to this relay
(none)Both read and write

Reading Relay Lists

async def get_relay_list(pubkey: str, bootstrap_relays: list) -> dict:
    """Get someone's relay preferences."""
    filter = {
        "kinds": [10002],
        "authors": [pubkey],
        "limit": 1
    }

    events = await query_events(bootstrap_relays, filter)
    if not events:
        return {"read": [], "write": []}

    read_relays = []
    write_relays = []

    for tag in events[0]["tags"]:
        if tag[0] != "r":
            continue

        url = tag[1]
        marker = tag[2] if len(tag) > 2 else None

        if marker == "read":
            read_relays.append(url)
        elif marker == "write":
            write_relays.append(url)
        else:
            # No marker = both
            read_relays.append(url)
            write_relays.append(url)

    return {"read": read_relays, "write": write_relays}
const events = await pool.querySync(relays, {
  kinds: [10002],
  authors: [pubkey],
  limit: 1
});

const relayList = events[0]?.tags
  .filter(t => t[0] === 'r')
  .reduce((acc, t) => {
    const url = t[1];
    const marker = t[2];
    if (!marker || marker === 'read') acc.read.push(url);
    if (!marker || marker === 'write') acc.write.push(url);
    return acc;
  }, { read: [], write: [] });

Publishing Your Relay List

def create_relay_list(relays: list[dict], private_key: str) -> dict:
    """Create a relay list event."""
    tags = []
    for relay in relays:
        tag = ["r", relay["url"]]
        if relay.get("read") and not relay.get("write"):
            tag.append("read")
        elif relay.get("write") and not relay.get("read"):
            tag.append("write")
        # else no marker (both)
        tags.append(tag)

    event = create_event(
        kind=10002,
        content="",
        tags=tags,
        private_key=private_key
    )

    return event

# Usage
my_relays = [
    {"url": "wss://relay.damus.io", "read": True, "write": True},
    {"url": "wss://nos.lol", "read": True, "write": True},
    {"url": "wss://nostr.wine", "read": False, "write": True}  # Write only
]

event = create_relay_list(my_relays, MY_PRIVATE_KEY)
await publish(event, BOOTSTRAP_RELAYS)

Gossip Model

NIP-65 enables the “gossip model” for relay discovery:

1. Query bootstrap relays for user's kind 10002
2. Use those relays to find user's content
3. Cache relay preferences for efficiency

Implementation

class RelayGossip:
    def __init__(self, bootstrap_relays: list):
        self.bootstrap = bootstrap_relays
        self.cache = {}  # pubkey -> relay list

    async def get_relays_for_user(self, pubkey: str) -> dict:
        """Get relays for a specific user."""
        if pubkey in self.cache:
            return self.cache[pubkey]

        relay_list = await get_relay_list(pubkey, self.bootstrap)
        self.cache[pubkey] = relay_list
        return relay_list

    async def fetch_user_content(self, pubkey: str, kind: int) -> list:
        """Fetch content from user's preferred relays."""
        relays = await self.get_relays_for_user(pubkey)

        # Query their write relays (where they publish)
        query_relays = relays["write"] or self.bootstrap

        filter = {
            "kinds": [kind],
            "authors": [pubkey]
        }

        return await query_events(query_relays, filter)

    async def publish_to_user(self, pubkey: str, event: dict):
        """Publish to user's read relays (where they'll see it)."""
        relays = await self.get_relays_for_user(pubkey)

        # Publish to their read relays
        target_relays = relays["read"] or self.bootstrap
        await publish(event, target_relays)

Outbox Model

The “outbox model” uses NIP-65 for efficient social graph traversal:

async def build_feed(my_pubkey: str, limit: int = 50) -> list:
    """Build feed using outbox model."""
    # Get who I follow
    contacts = await get_contacts(my_pubkey, BOOTSTRAP_RELAYS)
    following = [c["pubkey"] for c in contacts]

    # Get each user's relay preferences
    relay_map = {}
    for pk in following:
        relay_list = await get_relay_list(pk, BOOTSTRAP_RELAYS)
        relay_map[pk] = relay_list["write"] or BOOTSTRAP_RELAYS

    # Query each user's outbox relays
    all_events = []
    for pk, relays in relay_map.items():
        events = await query_events(relays, {
            "kinds": [1],
            "authors": [pk],
            "limit": 10
        })
        all_events.extend(events)

    # Dedupe and sort
    seen = set()
    unique = []
    for e in all_events:
        if e["id"] not in seen:
            seen.add(e["id"])
            unique.append(e)

    return sorted(unique, key=lambda e: e["created_at"], reverse=True)[:limit]

Best Practices

Relay Selection

  • Include 3-5 relays for redundancy
  • Mix popular and personal relays
  • Consider geographic diversity
  • Update if relays become unreliable

For Agents

# Recommended relay configuration for agents
AGENT_RELAYS = [
    # Popular, reliable
    {"url": "wss://relay.damus.io", "read": True, "write": True},
    {"url": "wss://nos.lol", "read": True, "write": True},

    # Good for search/discovery
    {"url": "wss://relay.nostr.band", "read": True, "write": True},

    # Paid (less spam)
    {"url": "wss://nostr.wine", "read": False, "write": True}
]

Caching

import time

class RelayCache:
    def __init__(self, ttl: int = 3600):  # 1 hour default
        self.cache = {}
        self.ttl = ttl

    def get(self, pubkey: str) -> dict | None:
        if pubkey in self.cache:
            entry = self.cache[pubkey]
            if time.time() - entry["time"] < self.ttl:
                return entry["relays"]
        return None

    def set(self, pubkey: str, relays: dict):
        self.cache[pubkey] = {
            "relays": relays,
            "time": time.time()
        }

Replaceable Event

Kind 10002 is replaceable—only the latest version per pubkey is valid. Clients should:

  1. Always query for the most recent
  2. Replace cached versions when newer found
  3. Handle missing relay lists gracefully

Machine-Readable Summary

{
  "nip": 65,
  "title": "Relay List Metadata",
  "status": "final",
  "defines": [
    "relay-list-format",
    "read-write-markers",
    "gossip-model"
  ],
  "event_kind": 10002,
  "replaceable": true,
  "tag_format": ["r", "relay-url", "read|write?"],
  "discovery_model": "outbox",
  "related": [
    "/learn/nostr/relays",
    "/learn/nostr/specs/nip-01",
    "/learn/nostr/specs/nip-02"
  ]
}