NIP-33

Parameterized Replaceable Events

draft core protocol

NIP-33 extends replaceable events with 'd' tag identifiers, creating addressable events (kind:pubkey:d) for articles, profiles, and custom content.

Author
Semisol, fiatjaf
Last Updated
18 January 2024
Official Spec
View on GitHub →

NIP-33: Parameterized Replaceable Events

Status: Draft Authors: Semisol, fiatjaf Category: Core Protocol


Overview

NIP-33 defines parameterized replaceable events (kinds 30000-39999) with unique identifiers, enabling updatable content with stable, addressable references.

Core Concept:

  • Extend replaceable events (NIP-16) with parameters
  • Use d tag as unique identifier
  • Address events by kind:pubkey:d-value
  • Multiple replaceable events per user per kind
  • Stable references that survive updates

Key Features:

  • Unique identifiers: d tag for addressing
  • Multiple per kind: Many replaceable events per user
  • Stable addresses: Reference survives updates
  • Flexible uses: Articles, profiles, calendars, more
  • Efficient queries: Fetch by address coordinate

Status: Draft (widely used for articles, lists, profiles)


Why Parameterized Replaceable Events Matter

Addressing Problem

Regular Events (kind 1):

  • Immutable, permanent IDs
  • Can’t update without losing reference
  • New event = new ID

Replaceable Events (kind 10000-19999):

  • One per user per kind
  • Limited to single instance
  • Can’t have multiple profiles, articles, etc.

Parameterized Replaceable (kind 30000-39999):

  • Multiple per user per kind
  • Each has unique d tag identifier
  • Update without breaking references
  • Perfect for articles, collections, configurations

How It Works

Event Structure

{
  "kind": 30023,  // Article (parameterized replaceable)
  "content": "# My Article\n\nContent here...",
  "tags": [
    ["d", "my-first-article"],  // Unique identifier
    ["title", "My First Article"],
    ["published_at", "1673347337"]
  ],
  "pubkey": "author-pubkey",
  "created_at": 1673347337,
  "id": "event-id",
  "sig": "signature"
}

Key Points:

  • d tag is required and unique per kind per pubkey
  • Only latest event with same (kind, pubkey, d) kept
  • Earlier events with same coordinate replaced

Address Coordinate

Events addressed by coordinate triplet:

kind:pubkey:d-value

Examples:

30023:3bf0c63f...:my-first-article      (article)
30000:3bf0c63f...:bookmarks             (bookmark list)
30078:3bf0c63f...:my-app-settings       (app settings)

Kind Ranges

Standard Kinds

RangePurposeReplace Scope
30000-30099Application dataLists, settings, state
30100-30299ReservedFuture use
30300-30399Social/CommunityGroups, channels
30400-39999CustomApplication-specific

Specific Kinds:

  • 30000: Categorized people lists (NIP-51)
  • 30001: Categorized bookmark lists (NIP-51)
  • 30008: Profile badges (NIP-58)
  • 30009: Badge definitions (NIP-58)
  • 30023: Long-form article (NIP-23)
  • 30078: Application-specific data
  • 30311: Live event (streaming, spaces)
  • 30315: User statuses
  • 30402: Classified listing

Creating Parameterized Replaceable Events

Basic Creation

import { getPublicKey, signEvent } from 'nostr-tools';

function createParameterizedEvent(kind, identifier, content, additionalTags, privateKey) {
  const event = {
    kind: kind,
    content: content,
    tags: [
      ["d", identifier],  // Required!
      ...additionalTags
    ],
    created_at: Math.floor(Date.now() / 1000),
    pubkey: getPublicKey(privateKey)
  };

  return signEvent(event, privateKey);
}

// Usage: Create article
const article = createParameterizedEvent(
  30023,  // Article kind
  "bitcoin-for-beginners",  // Unique ID (slug)
  "# Bitcoin for Beginners\n\nLearn about Bitcoin...",
  [
    ["title", "Bitcoin for Beginners"],
    ["summary", "A comprehensive guide"],
    ["published_at", String(Math.floor(Date.now() / 1000))]
  ],
  myPrivateKey
);

await publishToRelays(article);

Article (NIP-23 Example)

function createArticle(slug, title, content, metadata, privateKey) {
  const tags = [
    ["d", slug],  // Unique article identifier
    ["title", title]
  ];

  // Optional metadata
  if (metadata.summary) tags.push(["summary", metadata.summary]);
  if (metadata.image) tags.push(["image", metadata.image]);
  if (metadata.publishedAt) tags.push(["published_at", String(metadata.publishedAt)]);
  if (metadata.tags) {
    metadata.tags.forEach(tag => tags.push(["t", tag]));
  }

  return createParameterizedEvent(30023, slug, content, tags.slice(1), privateKey);
}

// Usage
const article = createArticle(
  "nostr-guide-2024",
  "Complete Nostr Guide 2024",
  markdownContent,
  {
    summary: "Everything you need to know about Nostr",
    image: "https://example.com/cover.jpg",
    publishedAt: Date.now() / 1000,
    tags: ["nostr", "guide", "tutorial"]
  },
  myPrivateKey
);

Application Settings

function saveAppSettings(appName, settings, privateKey) {
  const settingsJSON = JSON.stringify(settings);

  return createParameterizedEvent(
    30078,  // Application-specific data
    appName,  // App identifier
    settingsJSON,
    [],
    privateKey
  );
}

// Usage
const settings = await saveAppSettings(
  "my-nostr-client",
  {
    theme: "dark",
    defaultRelays: ["wss://relay.damus.io"],
    notifications: true
  },
  myPrivateKey
);

await publishToRelays(settings);

Fetching by Address

Query by Coordinate

async function fetchByAddress(kind, pubkey, identifier, pool, relays) {
  const filters = {
    kinds: [kind],
    authors: [pubkey],
    "#d": [identifier]
  };

  const events = await pool.list(relays, [filters]);

  // Should only be one (latest)
  return events[0] || null;
}

// Usage: Fetch specific article
const article = await fetchByAddress(
  30023,  // Article kind
  authorPubkey,
  "bitcoin-for-beginners",  // Article slug
  pool,
  relays
);

console.log(article.content);  // Article markdown

Query All Events of Kind

async function fetchAllByKind(kind, pubkey, pool, relays) {
  const filters = {
    kinds: [kind],
    authors: [pubkey]
  };

  const events = await pool.list(relays, [filters]);

  // Deduplicate by d tag (keep latest)
  const deduplicated = new Map();

  events.forEach(event => {
    const dTag = event.tags.find(t => t[0] === 'd');
    if (!dTag) return;

    const identifier = dTag[1];
    const existing = deduplicated.get(identifier);

    if (!existing || event.created_at > existing.created_at) {
      deduplicated.set(identifier, event);
    }
  });

  return Array.from(deduplicated.values());
}

// Usage: Get all articles by author
const articles = await fetchAllByKind(30023, authorPubkey, pool, relays);
console.log(`Found ${articles.length} articles`);

Updating Events

Update Without Breaking References

async function updateArticle(slug, newContent, metadata, privateKey, pool, relays) {
  const pubkey = getPublicKey(privateKey);

  // Fetch existing (optional, to preserve metadata)
  const existing = await fetchByAddress(30023, pubkey, slug, pool, relays);

  // Create updated version (same slug = replaces old)
  const updated = createArticle(
    slug,  // Same identifier!
    metadata.title || existing?.tags.find(t => t[0] === 'title')?.[1],
    newContent,
    metadata,
    privateKey
  );

  // Publish (automatically replaces old version)
  await publishToRelays(updated);

  return updated;
}

// Usage: Update article
const updated = await updateArticle(
  "bitcoin-for-beginners",
  updatedMarkdown,
  {
    title: "Bitcoin for Beginners (Updated)",
    summary: "Updated with 2024 information"
  },
  myPrivateKey,
  pool,
  relays
);

console.log("Article updated! Same address, new content.");

Key Point: Using same d tag ensures old version is replaced, but address remains stable.


Address References (naddr)

Creating naddr URIs

import { nip19 } from 'nostr-tools';

function createAddressURI(kind, pubkey, identifier, relays = []) {
  const naddr = nip19.naddrEncode({
    kind: kind,
    pubkey: pubkey,
    identifier: identifier,
    relays: relays
  });

  return `nostr:${naddr}`;
}

// Usage
const uri = createAddressURI(
  30023,
  authorPubkey,
  "bitcoin-for-beginners",
  ["wss://relay.damus.io"]
);

// Result: nostr:naddr1qqxnzd3e...
// Always points to latest version of article

Parsing naddr

function parseAddressURI(uri) {
  const bech32 = uri.replace(/^nostr:/, '');
  const decoded = nip19.decode(bech32);

  if (decoded.type !== 'naddr') {
    throw new Error('Not an naddr URI');
  }

  return {
    kind: decoded.data.kind,
    pubkey: decoded.data.pubkey,
    identifier: decoded.data.identifier,
    relays: decoded.data.relays || []
  };
}

// Usage
const address = parseAddressURI("nostr:naddr1...");
const event = await fetchByAddress(
  address.kind,
  address.pubkey,
  address.identifier,
  pool,
  address.relays
);

Use Cases

1. Blog Platform

class BlogPlatform {
  async publishArticle(slug, article, privateKey) {
    const event = createArticle(slug, article.title, article.content, article.metadata, privateKey);
    await publishToRelays(event);
    return `nostr:${nip19.naddrEncode({
      kind: 30023,
      pubkey: getPublicKey(privateKey),
      identifier: slug
    })}`;
  }

  async getArticle(authorPubkey, slug) {
    return await fetchByAddress(30023, authorPubkey, slug, pool, relays);
  }

  async listArticles(authorPubkey) {
    return await fetchAllByKind(30023, authorPubkey, pool, relays);
  }

  async updateArticle(slug, updatedContent, privateKey) {
    return await updateArticle(slug, updatedContent, {}, privateKey, pool, relays);
  }
}

2. User Settings Sync

class SettingsManager {
  async saveSettings(appName, settings, privateKey) {
    const event = createParameterizedEvent(
      30078,
      appName,
      JSON.stringify(settings),
      [],
      privateKey
    );
    await publishToRelays(event);
  }

  async loadSettings(appName, pubkey) {
    const event = await fetchByAddress(30078, pubkey, appName, pool, relays);
    return event ? JSON.parse(event.content) : null;
  }

  async updateSettings(appName, updates, privateKey) {
    const current = await this.loadSettings(appName, getPublicKey(privateKey));
    const merged = { ...current, ...updates };
    await this.saveSettings(appName, merged, privateKey);
  }
}

// Usage
const settings = new SettingsManager();
await settings.saveSettings("my-app", { theme: "dark" }, privateKey);
const loaded = await settings.loadSettings("my-app", myPubkey);

3. Calendar Events

function createCalendarEvent(eventId, title, start, end, description, privateKey) {
  const tags = [
    ["d", eventId],
    ["title", title],
    ["start", String(start)],
    ["end", String(end)]
  ];

  return createParameterizedEvent(
    31922,  // Calendar event kind
    eventId,
    description,
    tags.slice(1),
    privateKey
  );
}

// Usage
const meetup = createCalendarEvent(
  "nostr-meetup-2024-01",
  "Nostr Meetup January 2024",
  1704067200,  // Start timestamp
  1704074400,  // End timestamp
  "Join us for the monthly Nostr meetup!",
  myPrivateKey
);

Advanced Patterns

Versioning

Track version history by keeping old events:

class VersionedContent {
  async publish(identifier, content, privateKey) {
    const event = createParameterizedEvent(
      30023,
      identifier,
      content,
      [["version", String(Date.now())]],
      privateKey
    );

    await publishToRelays(event);

    // Optionally store in version history
    await this.archiveVersion(event);

    return event;
  }

  async getVersionHistory(identifier, pubkey) {
    // Fetch all versions from relay that stores history
    const filters = {
      kinds: [30023],
      authors: [pubkey],
      "#d": [identifier]
    };

    return await pool.list(archiveRelays, [filters]);
  }
}

Collaborative Editing

function createCollaborativeDocument(docId, content, editors, privateKey) {
  const tags = [
    ["d", docId],
    ["title", "Collaborative Document"],
    ...editors.map(pubkey => ["p", pubkey, "", "editor"])
  ];

  return createParameterizedEvent(30078, docId, content, tags.slice(1), privateKey);
}

// Multiple users can publish updates
// Latest wins, but all are co-authors

Relay Behavior

Storage and Replacement

Relays MUST:

  1. Store latest event for each (kind, pubkey, d) coordinate
  2. Replace older events with same coordinate
  3. Index by d tag for efficient queries
  4. Support #d filter in REQ messages
// Relay pseudocode
function handleEvent(event) {
  if (event.kind >= 30000 && event.kind < 40000) {
    const dTag = event.tags.find(t => t[0] === 'd');
    if (!dTag) {
      return reject("Missing d tag");
    }

    const coordinate = `${event.kind}:${event.pubkey}:${dTag[1]}`;
    const existing = storage.get(coordinate);

    if (!existing || event.created_at > existing.created_at) {
      storage.set(coordinate, event);
      return accept();
    } else {
      return reject("Older or duplicate event");
    }
  }
}

Security Considerations

d Tag Validation

function validateParameterizedEvent(event) {
  // Check kind range
  if (event.kind < 30000 || event.kind >= 40000) {
    return { valid: false, error: "Wrong kind range" };
  }

  // Check for d tag
  const dTag = event.tags.find(t => t[0] === 'd');
  if (!dTag || !dTag[1]) {
    return { valid: false, error: "Missing or empty d tag" };
  }

  // Validate d tag format (no special chars recommended)
  const identifier = dTag[1];
  if (identifier.length > 256) {
    return { valid: false, error: "d tag too long" };
  }

  return { valid: true };
}

Identifier Collisions

// Prevent accidental collisions
function generateSafeIdentifier(base) {
  // Use slugified base + timestamp for uniqueness
  const slug = base.toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '');

  return `${slug}-${Date.now()}`;
}

// Or use UUIDs
import { v4 as uuidv4 } from 'uuid';
const safeId = uuidv4();

Client Support

Clients with NIP-33 Support

  • Habla - Full support for articles (30023)
  • Amethyst - Lists, badges, articles
  • Damus - Basic addressable event support
  • Primal - Article rendering
  • Snort - Badges and lists

Common Uses

ApplicationKindd Tag Pattern
Articles30023Article slug
Bookmark Lists30001”bookmark”, “bookmark-bitcoin”
User Status30315”general”, “music”
Calendar Events31922Event ID
App Settings30078App name

Common Questions

When should I use parameterized replaceable vs regular events?

Use parameterized replaceable when:

  • Content updates over time
  • Need stable references
  • Multiple instances per user

Use regular events when:

  • Content is immutable
  • Each post is unique
  • No need for updates

Can I have multiple d tags?

No. Only one d tag per event. It’s the unique identifier.

What if I omit the d tag?

Event is invalid for parameterized replaceable kinds (30000-39999).

Can d tags contain spaces?

Yes, but use slugs (kebab-case) for URLs/URIs.

How do I delete a parameterized event?

Publish a new event with same coordinate and empty/deleted content.


  • NIP-01 - Basic protocol (event structure)
  • NIP-16 - Regular replaceable events (10000-19999)
  • NIP-23 - Long-form content (uses kind 30023)
  • NIP-51 - Lists (uses kind 30000, 30001)

Technical Specification

For the complete technical specification, see NIP-33 on GitHub.


Summary

NIP-33 enables addressable, updatable content on Nostr:

Parameterized replaceable events (30000-39999) ✅ d tag identifier for unique addressing ✅ Stable coordinates: kind:pubkey:d-value ✅ Multiple per user per kind ✅ Update without breaking references

Event structure:

{
  "kind": 30023,
  "content": "Article content...",
  "tags": [
    ["d", "my-article-slug"],
    ["title", "My Article"]
  ]
}

Address coordinate: 30023:pubkey:my-article-slug

Status: Draft - widely used for articles, lists, settings.

Best practice: Use descriptive, stable d tag values (slugs, UUIDs).


Next Steps:


Last updated: January 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

damus amethyst primal habla snort
View all clients →

Related NIPs

NIP-01 NIP-16 NIP-23 NIP-51
← Browse All NIPs