Public Chat (Channel Events)
NIP-28 enables public chat rooms on Nostr using three event kinds: kind 40 creates channels, kind 41 updates metadata, and kind 42 sends messages. Channels are public, persistent, and topic-based.
NIP-28: Public Chat
Status: Final Author: fiatjaf Category: Chat
Overview
NIP-28 defines public chat channels on Nostr — persistent, topic-based conversation spaces similar to IRC channels, Discord servers, or Telegram groups.
Key Features:
- ✅ Public channels: Anyone can read, permissionlessly
- ✅ Topic-based: Channels have names, descriptions, topics
- ✅ Persistent: Messages stored on relays like other events
- ✅ Decentralized: No central server controls channels
- ✅ Client-agnostic: Any Nostr client can implement
Three Event Kinds:
- Kind 40: Channel creation
- Kind 41: Channel metadata (name, about, picture)
- Kind 42: Channel messages
Why Public Chat?
Use Cases
- Topic Discussions: #bitcoin, #nostr-dev, #art, etc.
- Community Hubs: Project coordination, support channels
- Public Forums: Open discussion spaces
- Event Chat: Conference rooms, live event discussions
- Interest Groups: Hobbyists, professionals, fans
vs. Other Communication Modes
| Feature | Kind 1 (Posts) | Kind 4 (DMs) | Kind 28 (Chat) |
|---|---|---|---|
| Visibility | Public timeline | Private 1-on-1 | Public channel |
| Persistence | Permanent | Permanent | Permanent |
| Threading | Yes (NIP-10) | No | Optional |
| Organization | Timeline | Conversation | Channel/topic |
| Notifications | Mentions | Direct | Channel activity |
Public chat fills the gap between public timelines and private DMs.
How It Works
Event Types
Kind 40: Channel Creation
Creates a new channel:
{
"kind": 40,
"created_at": 1673347337,
"content": "{\"name\":\"Bitcoin Discussion\",\"about\":\"Talk about Bitcoin\",\"picture\":\"https://example.com/bitcoin.jpg\"}",
"tags": [],
"pubkey": "creator_public_key",
"id": "channel_event_id",
"sig": "..."
}
Content: JSON object with:
name: Channel name (required)about: Channel description (optional)picture: Channel icon URL (optional)
Channel ID: The event ID of this kind 40 event becomes the channel’s unique identifier.
Kind 41: Channel Metadata
Updates channel information:
{
"kind": 41,
"created_at": 1673347400,
"content": "{\"name\":\"Bitcoin 💰\",\"about\":\"Updated description\"}",
"tags": [
["e", "channel_event_id", "relay_url", "root"]
],
"pubkey": "creator_public_key",
"id": "...",
"sig": "..."
}
E tag: References the original channel creation event (kind 40)
Purpose: Update name, description, or picture without creating a new channel.
Kind 42: Channel Message
Send a message to the channel:
{
"kind": 42,
"created_at": 1673347500,
"content": "Hello everyone! Great to be here.",
"tags": [
["e", "channel_event_id", "relay_url", "root"],
["p", "mentioned_user_pubkey"]
],
"pubkey": "sender_public_key",
"id": "...",
"sig": "..."
}
E tag: References the channel (kind 40 event) P tags: (Optional) Mention users in the message
Creating a Channel
Example: Create a Channel
import { getPublicKey, signEvent } from 'nostr-tools';
function createChannel(name, about, picture, privateKey) {
const metadata = {
name: name,
about: about || "",
picture: picture || ""
};
const channelEvent = {
kind: 40,
created_at: Math.floor(Date.now() / 1000),
content: JSON.stringify(metadata),
tags: [],
pubkey: getPublicKey(privateKey)
};
return signEvent(channelEvent, privateKey);
}
// Usage
const channel = createChannel(
"Bitcoin Discussion",
"All things Bitcoin - news, tech, economics",
"https://example.com/bitcoin-icon.png",
privateKey
);
await relay.publish(channel);
console.log("Channel ID:", channel.id);
Example: Update Channel Metadata
function updateChannelMetadata(channelId, newMetadata, privateKey) {
const updateEvent = {
kind: 41,
created_at: Math.floor(Date.now() / 1000),
content: JSON.stringify(newMetadata),
tags: [
["e", channelId, "", "root"]
],
pubkey: getPublicKey(privateKey)
};
return signEvent(updateEvent, privateKey);
}
// Usage
const update = updateChannelMetadata(
channelId,
{
name: "Bitcoin 💰",
about: "Updated description with more details"
},
privateKey
);
await relay.publish(update);
Sending Messages
Example: Send Channel Message
function sendChannelMessage(channelId, message, privateKey) {
const messageEvent = {
kind: 42,
created_at: Math.floor(Date.now() / 1000),
content: message,
tags: [
["e", channelId, "", "root"]
],
pubkey: getPublicKey(privateKey)
};
return signEvent(messageEvent, privateKey);
}
// Usage
const message = sendChannelMessage(
channelId,
"GM everyone! ☀️",
privateKey
);
await relay.publish(message);
Example: Mention User in Channel
function sendMessageWithMention(channelId, message, mentionedPubkey, privateKey) {
const messageEvent = {
kind: 42,
created_at: Math.floor(Date.now() / 1000),
content: message,
tags: [
["e", channelId, "", "root"],
["p", mentionedPubkey]
],
pubkey: getPublicKey(privateKey)
};
return signEvent(messageEvent, privateKey);
}
// Usage
const mention = sendMessageWithMention(
channelId,
"Hey @alice, what do you think about this?",
alicePubkey,
privateKey
);
await relay.publish(mention);
Fetching Channels
List All Channels
async function listChannels(relays, limit = 100) {
const filters = {
kinds: [40],
limit: limit
};
const channels = await Promise.all(
relays.map(relay => relay.list([filters]))
);
return channels.flat();
}
// Usage
const channels = await listChannels(myRelays);
console.log(`Found ${channels.length} channels`);
Get Channel Metadata
async function getChannelMetadata(channelId, relays) {
// Fetch channel creation event
const creation = await relay.get({
ids: [channelId]
});
// Fetch latest metadata updates
const updates = await relay.list({
kinds: [41],
"#e": [channelId]
});
// Use latest update if exists, otherwise creation
if (updates.length > 0) {
const latest = updates.sort((a, b) => b.created_at - a.created_at)[0];
return JSON.parse(latest.content);
}
return JSON.parse(creation.content);
}
// Usage
const metadata = await getChannelMetadata(channelId, myRelays);
console.log("Channel:", metadata.name);
console.log("About:", metadata.about);
Fetch Channel Messages
async function getChannelMessages(channelId, relays, limit = 100) {
const filters = {
kinds: [42],
"#e": [channelId],
limit: limit
};
const messages = await Promise.all(
relays.map(relay => relay.list([filters]))
);
// Flatten, sort by timestamp
return messages
.flat()
.sort((a, b) => a.created_at - b.created_at);
}
// Usage
const messages = await getChannelMessages(channelId, myRelays, 50);
console.log(`${messages.length} messages in channel`);
Subscribing to Channels
Real-Time Message Stream
function subscribeToChannel(channelId, relays, onMessage) {
const filters = {
kinds: [42],
"#e": [channelId],
since: Math.floor(Date.now() / 1000) // From now
};
relays.forEach(relay => {
relay.sub([filters], {
onEvent: (message) => {
onMessage(message);
}
});
});
}
// Usage
subscribeToChannel(channelId, myRelays, (message) => {
console.log(`New message from ${message.pubkey}:`);
console.log(message.content);
});
Channel Management
Moderation
⚠️ No Built-In Moderation: NIP-28 doesn’t define moderation mechanisms.
Client-Side Approaches:
- Mute Users: Hide messages from specific pubkeys
- Web-of-Trust: Show only messages from trusted users
- Report System: Flag inappropriate content (client-specific)
- Relay Filtering: Some relays may filter spam/abuse
Example: Client-Side Mute:
const mutedUsers = new Set(["pubkey1", "pubkey2"]);
function filterMessages(messages) {
return messages.filter(msg => !mutedUsers.has(msg.pubkey));
}
Channel Ownership
No Enforcement: The creator’s pubkey is recorded but:
- Anyone can send messages
- Anyone can publish metadata updates (clients should use latest from creator)
- No built-in permissions system
Client Best Practices:
- Display original creator prominently
- Only show metadata from original creator
- Warn if non-creator updates metadata
function isCreator(channelCreatorPubkey, updateEvent) {
return updateEvent.pubkey === channelCreatorPubkey;
}
function getValidMetadata(channelCreatorPubkey, updates) {
// Only accept updates from channel creator
const validUpdates = updates.filter(u =>
u.pubkey === channelCreatorPubkey
);
// Return latest valid update
return validUpdates.sort((a, b) => b.created_at - a.created_at)[0];
}
UI Patterns
Channel List Display
function ChannelList({ channels }) {
return (
<div className="channel-list">
{channels.map(channel => {
const metadata = JSON.parse(channel.content);
return (
<div key={channel.id} className="channel-item">
{metadata.picture && (
<img src={metadata.picture} alt={metadata.name} />
)}
<div className="channel-info">
<h3>{metadata.name}</h3>
<p>{metadata.about}</p>
</div>
<button onClick={() => joinChannel(channel.id)}>
Join
</button>
</div>
);
})}
</div>
);
}
Chat Message Display
function ChatMessage({ message, users }) {
const author = users[message.pubkey] || { name: "Unknown" };
return (
<div className="chat-message">
<Avatar user={author} />
<div className="message-content">
<div className="message-header">
<span className="author-name">{author.name}</span>
<span className="timestamp">
{formatTimestamp(message.created_at)}
</span>
</div>
<div className="message-text">
{message.content}
</div>
</div>
</div>
);
}
Live Chat Interface
function LiveChat({ channelId, relays, user }) {
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState("");
useEffect(() => {
// Load initial messages
getChannelMessages(channelId, relays, 100).then(setMessages);
// Subscribe to new messages
subscribeToChannel(channelId, relays, (message) => {
setMessages(prev => [...prev, message]);
});
}, [channelId]);
async function sendMessage() {
if (!inputText.trim()) return;
const message = sendChannelMessage(channelId, inputText, user.privateKey);
await relay.publish(message);
setInputText("");
}
return (
<div className="live-chat">
<div className="messages">
{messages.map(msg => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
<div className="input-area">
<input
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type a message..."
/>
<button onClick={sendMessage}>Send</button>
</div>
</div>
);
}
Advanced Features
Threading in Channels
Use NIP-10 reply conventions within channels:
function replyToChannelMessage(channelId, parentMessage, replyText, privateKey) {
const reply = {
kind: 42,
created_at: Math.floor(Date.now() / 1000),
content: replyText,
tags: [
["e", channelId, "", "root"], // Channel
["e", parentMessage.id, "", "reply"], // Parent message
["p", parentMessage.pubkey] // Parent author
],
pubkey: getPublicKey(privateKey)
};
return signEvent(reply, privateKey);
}
Channel Discovery
Recommend channels based on activity:
async function getPopularChannels(relays, limit = 20) {
// Fetch all channels
const channels = await listChannels(relays, 1000);
// Count messages per channel
const messageCounts = await Promise.all(
channels.map(async (channel) => {
const messages = await getChannelMessages(channel.id, relays, 1);
return { channel, count: messages.length };
})
);
// Sort by message count
return messageCounts
.sort((a, b) => b.count - a.count)
.slice(0, limit)
.map(item => item.channel);
}
Channel Search
function searchChannels(channels, query) {
const lowerQuery = query.toLowerCase();
return channels.filter(channel => {
const metadata = JSON.parse(channel.content);
const name = (metadata.name || "").toLowerCase();
const about = (metadata.about || "").toLowerCase();
return name.includes(lowerQuery) || about.includes(lowerQuery);
});
}
Security & Privacy
Public by Design
⚠️ All messages are public:
- Anyone can read channel messages
- Messages persist on relays
- No encryption (unless implemented separately)
Use Cases:
- Public discussions
- Open communities
- Transparent coordination
Not Suitable For:
- Private conversations (use NIP-04 DMs)
- Confidential information
- Sensitive topics
Spam Prevention
Without moderation, channels are vulnerable to spam:
Client-Side Mitigations:
- Rate Limiting: Limit messages per user per time period
- Proof-of-Work: Require NIP-13 PoW for messages
- Web-of-Trust: Prioritize messages from followed users
- Mute/Block: Let users hide spammers
- Relay Policies: Use relays with spam filtering
Limitations
No Access Control
- Anyone can post to any channel
- No way to enforce permissions
- No kick/ban mechanisms (at protocol level)
Workarounds:
- Client-side filtering
- Private channels (future NIPs)
- Paid relays with access control
No Message Ordering Guarantee
- Relays may serve messages in different orders
- Clients must sort by timestamp
- No guarantee of causality
Metadata Conflicts
Multiple metadata updates can create conflicts:
Best Practice: Only accept updates from channel creator.
Client Support
Full NIP-28 Support
- Amethyst - Comprehensive channel support
- Nostrudel - Advanced chat features
- Satellite - Reddit-like communities
- Coracle - Chat rooms and threads
Partial Support
- Snort - Basic channel viewing
- Primal - Limited chat features
Many clients don’t yet implement NIP-28. Check our Client Directory.
Common Questions
How do I delete a channel?
You can’t. Once created, channel metadata persists on relays. You can stop using it, but it remains discoverable.
Can I make a private channel?
NIP-28 channels are public. For private groups, use:
- NIP-29 (moderated communities with access control)
- Group DMs (multiple users in encrypted conversations)
Who can update channel metadata?
Anyone can publish kind 41 updates, but clients should only display updates from the original creator.
How do I moderate spam?
Client-side filtering, muting, and web-of-trust. Some relays may filter spam server-side.
Can channels have permissions?
Not in NIP-28. See NIP-29 for more advanced community features with roles and permissions.
Related NIPs
- NIP-01 - Basic protocol (event structure)
- NIP-10 - Reply conventions (threading in channels)
- NIP-04 - Encrypted DMs (private alternative)
- NIP-29 - Relay-based groups (with moderation and permissions)
Technical Specification
For the complete technical specification, see NIP-28 on GitHub.
Summary
NIP-28 enables public chat on Nostr:
✅ Kind 40 creates channels ✅ Kind 41 updates channel metadata ✅ Kind 42 sends channel messages ✅ Public by design (anyone can read/write) ✅ Decentralized (no central server)
Typical flow:
- Create channel with kind 40
- Users send messages with kind 42
- Update metadata with kind 41 (optional)
Limitations: No built-in moderation, access control, or privacy.
Next Steps:
- Learn about search in NIP-50
- Explore replies in NIP-10
- Understand private messaging in NIP-04
- Browse all NIPs in our reference
Last updated: January 2024 Official specification: GitHub
Client Support
This NIP is supported by the following clients: