NIP-28

Public Chat (Channel Events)

final chat

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.

Author
fiatjaf
Last Updated
15 January 2024
Official Spec
View on GitHub →

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

  1. Topic Discussions: #bitcoin, #nostr-dev, #art, etc.
  2. Community Hubs: Project coordination, support channels
  3. Public Forums: Open discussion spaces
  4. Event Chat: Conference rooms, live event discussions
  5. Interest Groups: Hobbyists, professionals, fans

vs. Other Communication Modes

FeatureKind 1 (Posts)Kind 4 (DMs)Kind 28 (Chat)
VisibilityPublic timelinePrivate 1-on-1Public channel
PersistencePermanentPermanentPermanent
ThreadingYes (NIP-10)NoOptional
OrganizationTimelineConversationChannel/topic
NotificationsMentionsDirectChannel 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:

  1. Mute Users: Hide messages from specific pubkeys
  2. Web-of-Trust: Show only messages from trusted users
  3. Report System: Flag inappropriate content (client-specific)
  4. 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);
}

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:

  1. Rate Limiting: Limit messages per user per time period
  2. Proof-of-Work: Require NIP-13 PoW for messages
  3. Web-of-Trust: Prioritize messages from followed users
  4. Mute/Block: Let users hide spammers
  5. 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.


  • 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:

  1. Create channel with kind 40
  2. Users send messages with kind 42
  3. Update metadata with kind 41 (optional)

Limitations: No built-in moderation, access control, or privacy.


Next Steps:


Last updated: January 2024 Official specification: GitHub

Client Support

This NIP is supported by the following clients:

amethyst nostrudel satellite coracle snort
View all clients →

Related NIPs

NIP-01 NIP-10 NIP-04
← Browse All NIPs