NIP-33: Parameterized Replaceable Events (Addressable Events)
Technical Report on Nostr's Addressable Event System
Last Updated: October 2024
Executive Summary
NIP-33 defines a class of Nostr events called "Parameterized Replaceable Events," now commonly referred to as "Addressable Events." This specification has been merged into NIP-01 as a core component of the Nostr protocol. These events enable applications to maintain current state rather than append-only history, making them ideal for profiles, status updates, application settings, and other data that should be replaced rather than accumulated.
1. Introduction
1.1 Background
The Nostr protocol was initially designed around immutable, append-only events. While this works well for social media posts and messages, many applications require the ability to update existing data. NIP-33 addresses this need by introducing a standardized mechanism for replaceable events with unique identifiers.
1.2 Problem Statement
Traditional Nostr events (kinds 0-29999) are either: - Regular events: Immutable and append-only - Replaceable events (kinds 0, 3, 10000-19999): Can be replaced, but only one per kind per author
This limitation meant applications couldn't maintain multiple pieces of replaceable data of the same type. For example, a user couldn't have multiple editable lists or multiple status messages for different contexts.
2. Technical Specification
2.1 Event Kind Range
Parameterized replaceable events use kind numbers in the range:
30000 β€ kind < 40000
Any event with a kind in this range is automatically treated as a parameterized replaceable event by compliant relays and clients.
2.2 The "d" Tag Identifier
The critical component of NIP-33 is the "d" tag (identifier tag). This tag serves as a unique identifier within the scope of the event kind and author.
Structure:
["d", "<identifier>"]
Example:
{
"kind": 30078,
"tags": [
["d", "current-status"]
],
"content": "Working on Nostr development",
"pubkey": "abc123...",
"created_at": 1698000000,
"id": "def456...",
"sig": "789ghi..."
}
2.3 Replacement Logic
A relay MUST replace an older event with a newer one if and only if:
- Both events have the same
kind - Both events have the same
pubkey(author) - Both events have the same
dtag value - The new event has a higher
created_attimestamp
Pseudocode:
def should_replace(existing_event, new_event):
return (
existing_event.kind == new_event.kind and
existing_event.pubkey == new_event.pubkey and
existing_event.get_d_tag() == new_event.get_d_tag() and
new_event.created_at > existing_event.created_at
)
2.4 Addressable Event Identifier
Events can be uniquely addressed using the format:
<kind>:<pubkey>:<d-tag-value>
Example:
30078:3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d:current-status
This addressing scheme allows clients to: - Request specific events from relays - Reference events without knowing their event ID - Construct URLs and deep links
3. Common Use Cases
3.1 User Status Updates
Kind: 30078 (Application-specific data)
{
"kind": 30078,
"tags": [
["d", "status"]
],
"content": "π Shipping new features",
"created_at": 1698000000
}
3.2 Long-form Content (NIP-23)
Kind: 30023 (Long-form content)
{
"kind": 30023,
"tags": [
["d", "my-first-blog-post"],
["title", "Understanding NIP-33"],
["published_at", "1698000000"],
["t", "nostr"],
["t", "protocol"]
],
"content": "# Understanding NIP-33\n\nThis is a long-form article...",
"created_at": 1698000000
}
3.3 Application Settings
Kind: 30078
{
"kind": 30078,
"tags": [
["d", "app-settings"],
["theme", "dark"],
["language", "en"]
],
"content": "{\"notifications\": true, \"autoplay\": false}",
"created_at": 1698000000
}
3.4 Product Listings (NIP-15)
Kind: 30017 (Product listing)
{
"kind": 30017,
"tags": [
["d", "product-abc-123"],
["title", "Vintage Nostr T-Shirt"],
["price", "21000", "sats"],
["image", "https://example.com/shirt.jpg"]
],
"content": "Limited edition Nostr t-shirt from 2023",
"created_at": 1698000000
}
4. Implementation Guide
4.1 Creating an Addressable Event
Step 1: Define the event structure
const unsignedEvent = {
kind: 30078, // Parameterized replaceable kind
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", "my-identifier"] // Required: unique identifier
],
content: "Event content here"
};
Step 2: Sign the event
// Using window.nostr (browser extension)
const signedEvent = await window.nostr.signEvent(unsignedEvent);
// Or using a library like nostr-tools
import { getEventHash, signEvent } from 'nostr-tools';
unsignedEvent.pubkey = myPublicKey;
unsignedEvent.id = getEventHash(unsignedEvent);
unsignedEvent.sig = signEvent(unsignedEvent, myPrivateKey);
Step 3: Publish to relays
const relay = new WebSocket('wss://relay.example.com');
relay.onopen = () => {
const message = JSON.stringify(['EVENT', signedEvent]);
relay.send(message);
};
relay.onmessage = (event) => {
const [type, eventId, accepted, message] = JSON.parse(event.data);
if (type === 'OK' && accepted) {
console.log('Event published successfully');
}
};
4.2 Querying Addressable Events
By address (recommended):
const filter = {
kinds: [30078],
authors: ["3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
"#d": ["current-status"]
};
const subscription = JSON.stringify(['REQ', subscriptionId, filter]);
relay.send(subscription);
By kind and author:
const filter = {
kinds: [30078],
authors: ["3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"]
};
4.3 Relay Implementation Considerations
Relays implementing NIP-33 must:
- Index by composite key: Store events indexed by
(kind, pubkey, d-tag) - Replace on duplicate: When receiving an event with an existing address, compare timestamps
- Maintain only latest: Only store the most recent event for each unique address
- Support tag queries: Enable filtering by
#dtag in REQ messages
Database schema example:
CREATE TABLE addressable_events (
kind INTEGER NOT NULL,
pubkey TEXT NOT NULL,
d_tag TEXT NOT NULL,
created_at INTEGER NOT NULL,
event_id TEXT NOT NULL,
content TEXT,
tags JSON,
sig TEXT NOT NULL,
PRIMARY KEY (kind, pubkey, d_tag)
);
CREATE INDEX idx_created_at ON addressable_events(created_at);
CREATE INDEX idx_kind ON addressable_events(kind);
5. Comparison with Other Event Types
| Feature | Regular Events | Replaceable Events | Addressable Events |
|---|---|---|---|
| Kind Range | 1000-9999, 20000-29999 | 0, 3, 10000-19999 | 30000-39999 |
| Mutability | Immutable | Replaceable | Replaceable |
| Identifier | Event ID only | Kind + Pubkey | Kind + Pubkey + d-tag |
| Multiple per kind | Unlimited | One per author | Unlimited per author |
| Use Case | Posts, messages | Profile, contact list | Articles, settings, listings |
6. Security Considerations
6.1 Timestamp Manipulation
Risk: Malicious actors could set future timestamps to prevent updates.
Mitigation: Relays should reject events with timestamps too far in the future (e.g., > 10 minutes).
const MAX_FUTURE_DRIFT = 600; // 10 minutes
function isValidTimestamp(event) {
const now = Math.floor(Date.now() / 1000);
return event.created_at <= now + MAX_FUTURE_DRIFT;
}
6.2 Identifier Collisions
Risk: Using common or predictable d tag values could lead to unintended replacements.
Best Practice: Use descriptive, unique identifiers:
// β Bad: Generic identifier
["d", "post"]
// β
Good: Specific identifier
["d", "blog-post-understanding-nip33-2024-10-29"]
// β
Good: UUID for uniqueness
["d", "550e8400-e29b-41d4-a716-446655440000"]
6.3 Relay Trust
Risk: Malicious relays could refuse to replace events or serve outdated versions.
Mitigation: - Query multiple relays - Verify timestamps client-side - Use trusted relay networks
7. Real-World Applications
7.1 Blogging Platforms
Platforms like Habla.news and Yakihonne use NIP-33 (kind 30023) for long-form content, allowing authors to edit articles while maintaining a stable address.
7.2 Marketplaces
Nostr marketplaces use kinds 30017-30018 for product listings that can be updated as inventory changes.
7.3 Profile Extensions
Applications extend user profiles beyond NIP-01's kind 0 using addressable events for additional metadata, preferences, and settings.
7.4 Live Events
Event organizers use addressable events to update event details, schedules, and announcements while maintaining a consistent reference.
8. Best Practices
8.1 Choosing the Right Event Type
βββββββββββββββββββββββββββββββββββββββββββ
β Does the data need to be updated? β
βββββββββββββββ¬ββββββββββββββββββββββββββββ
β
ββ No βββ Use regular event (kind 1000-9999)
β
ββ Yes βββ¬β Only one per author?
β
ββ Yes βββ Use replaceable event (kind 10000-19999)
β
ββ No ββββ Use addressable event (kind 30000-39999)
8.2 Naming Conventions for "d" Tags
- Use kebab-case:
my-blog-postinstead ofmyBlogPost - Be descriptive:
settings-themeinstead ofst - Include context:
article-nip33-guideinstead ofarticle - Avoid special characters: Stick to alphanumeric and hyphens
8.3 Content Organization
{
"kind": 30078,
"tags": [
["d", "app-config"],
["version", "1.0"],
["updated", "2024-10-29"]
],
"content": "{\"structured\": \"data\", \"goes\": \"here\"}"
}
Recommendation: Use tags for queryable metadata, content for bulk data.
9. Testing Your Implementation
9.1 Test Event Creation
async function testAddressableEvent() {
const event = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [["d", "test-event"]],
content: "Test content"
};
const signed = await window.nostr.signEvent(event);
console.log('Signed event:', signed);
// Verify structure
assert(signed.kind === 30078);
assert(signed.tags.some(t => t[0] === 'd'));
assert(signed.id && signed.sig);
}
9.2 Test Replacement Logic
async function testReplacement() {
// Create first event
const event1 = await createAndSign({
kind: 30078,
tags: [["d", "test"]],
content: "Version 1"
});
await publishToRelay(event1);
await sleep(1000);
// Create replacement
const event2 = await createAndSign({
kind: 30078,
tags: [["d", "test"]],
content: "Version 2"
});
await publishToRelay(event2);
// Query relay
const result = await queryRelay({
kinds: [30078],
"#d": ["test"]
});
// Should only return latest
assert(result.length === 1);
assert(result[0].content === "Version 2");
}
10. Future Developments
10.1 Potential Enhancements
- Versioning: Maintaining event history while still supporting replacement
- Partial updates: Updating specific fields without resending entire content
- Collaborative editing: Multiple authors for single addressable events
- Expiration: Automatic deletion of addressable events after a time period
10.2 Related NIPs
- NIP-01: Basic protocol flow (now includes NIP-33)
- NIP-23: Long-form content (uses kind 30023)
- NIP-51: Lists (uses kinds 30000-30003)
- NIP-72: Moderated communities (uses kind 34550)
11. Conclusion
NIP-33 Addressable Events represent a crucial evolution in the Nostr protocol, enabling stateful applications while maintaining the protocol's decentralized nature. By providing a standardized mechanism for replaceable, identifiable events, NIP-33 unlocks use cases ranging from blogging platforms to marketplaces to application settings.
The elegant designβusing kind ranges and a simple "d" tagβdemonstrates Nostr's philosophy of minimal, composable specifications. As the ecosystem grows, addressable events will continue to be a foundational building block for sophisticated applications.
12. References
- NIP-01: Basic Protocol Flow
- NIP-33: Parameterized Replaceable Events
- NIP-23: Long-form Content
- Nostr Protocol Documentation
Appendix A: Complete Example
/**
* Complete example: Creating and publishing an addressable event
*/
class AddressableEventManager {
constructor(relayUrl) {
this.relayUrl = relayUrl;
this.relay = null;
}
async connect() {
return new Promise((resolve, reject) => {
this.relay = new WebSocket(this.relayUrl);
this.relay.onopen = () => resolve();
this.relay.onerror = (err) => reject(err);
});
}
async createEvent(identifier, content, additionalTags = []) {
const event = {
kind: 30078,
created_at: Math.floor(Date.now() / 1000),
tags: [
["d", identifier],
...additionalTags
],
content: content
};
// Sign with browser extension
return await window.nostr.signEvent(event);
}
async publish(event) {
return new Promise((resolve, reject) => {
const message = JSON.stringify(['EVENT', event]);
const handler = (msg) => {
const [type, eventId, accepted, reason] = JSON.parse(msg.data);
if (type === 'OK') {
this.relay.removeEventListener('message', handler);
if (accepted) {
resolve({ success: true, eventId });
} else {
reject(new Error(reason));
}
}
};
this.relay.addEventListener('message', handler);
this.relay.send(message);
// Timeout after 5 seconds
setTimeout(() => {
this.relay.removeEventListener('message', handler);
reject(new Error('Publish timeout'));
}, 5000);
});
}
async query(filter) {
return new Promise((resolve) => {
const subId = Math.random().toString(36).substring(7);
const events = [];
const handler = (msg) => {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT' && data[1] === subId) {
events.push(data[2]);
} else if (data[0] === 'EOSE' && data[1] === subId) {
this.relay.removeEventListener('message', handler);
resolve(events);
}
};
this.relay.addEventListener('message', handler);
this.relay.send(JSON.stringify(['REQ', subId, filter]));
});
}
getAddress(event) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
return `${event.kind}:${event.pubkey}:${dTag}`;
}
}
// Usage
async function main() {
const manager = new AddressableEventManager('wss://nos.lol/');
await manager.connect();
// Create and publish
const event = await manager.createEvent(
'my-status',
'Building on Nostr! π',
[['emoji', 'π']]
);
console.log('Address:', manager.getAddress(event));
await manager.publish(event);
console.log('Published successfully!');
// Query back
const results = await manager.query({
kinds: [30078],
'#d': ['my-status'],
authors: [event.pubkey]
});
console.log('Retrieved:', results);
}
Document Version: 1.0
Author: Plebeius Garagicus
Date: October 29, 2024
License: Public Domain