Parameterized Replaceable Events
NIP-33 extends replaceable events with 'd' tag identifiers, creating addressable events (kind:pubkey:d) for articles, profiles, and custom content.
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
dtag 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:
dtag 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
dtag 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:
dtag 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
| Range | Purpose | Replace Scope |
|---|---|---|
| 30000-30099 | Application data | Lists, settings, state |
| 30100-30299 | Reserved | Future use |
| 30300-30399 | Social/Community | Groups, channels |
| 30400-39999 | Custom | Application-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:
- Store latest event for each
(kind, pubkey, d)coordinate - Replace older events with same coordinate
- Index by
dtag for efficient queries - Support
#dfilter 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
| Application | Kind | d Tag Pattern |
|---|---|---|
| Articles | 30023 | Article slug |
| Bookmark Lists | 30001 | ”bookmark”, “bookmark-bitcoin” |
| User Status | 30315 | ”general”, “music” |
| Calendar Events | 31922 | Event ID |
| App Settings | 30078 | App 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.
Related NIPs
- 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:
- Learn about articles in NIP-23
- Explore lists in NIP-51
- Understand URI schemes in NIP-21
- Browse all NIPs in our reference
Last updated: January 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: