Skip to main content

Direct Messages

Direct messages in ActivityPub aren't a separate message type—they're regular Notes with restricted addressing. A "direct message" is simply a post addressed only to specific users, without the public or followers collection. This guide covers implementing private messaging.

How DMs Work

There are no "visibility levels" in the ActivityPub spec. Privacy is controlled entirely through addressing:

┌─────────────────────────────────────────────────────────┐
│ VISIBILITY BY ADDRESSING │
├─────────────────────────────────────────────────────────┤
│ │
│ Public: to: [as:Public], cc: [followers] │
│ Unlisted: to: [followers], cc: [as:Public] │
│ Followers: to: [followers], cc: [] │
│ Direct: to: [user1, user2], cc: [] │
│ │
│ No as:Public + No followers = Direct Message │
│ │
└─────────────────────────────────────────────────────────┘

Sending a Direct Message

DM Note Format

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/notes/dm-123",
"type": "Note",
"attributedTo": "https://example.com/users/alice",
"to": ["https://other.example/users/bob"],
"cc": [],
"content": "<p>Hey Bob, this is a private message!</p>",
"published": "2024-01-15T10:30:00Z",
"tag": [
{
"type": "Mention",
"href": "https://other.example/users/bob",
"name": "@bob@other.example"
}
]
}

Create Activity for DM

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activities/create-dm-123",
"type": "Create",
"actor": "https://example.com/users/alice",
"to": ["https://other.example/users/bob"],
"cc": [],
"object": {
"id": "https://example.com/notes/dm-123",
"type": "Note",
"attributedTo": "https://example.com/users/alice",
"to": ["https://other.example/users/bob"],
"content": "<p>Hey Bob, this is a private message!</p>"
}
}

Implementation

async function sendDirectMessage(sender, recipientIds, content) {
const noteId = `https://${config.domain}/notes/${uuid()}`;
const activityId = `https://${config.domain}/activities/${uuid()}`;
const published = new Date().toISOString();

// Build mention tags
const tags = await Promise.all(
recipientIds.map(async (recipientId) => {
const recipient = await fetchActor(recipientId);
return {
type: 'Mention',
href: recipientId,
name: `@${recipient.preferredUsername}@${new URL(recipientId).hostname}`
};
})
);

const note = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: noteId,
type: 'Note',
attributedTo: sender.id,
to: recipientIds,
cc: [],
content: sanitizeHtml(content),
published,
tag: tags
};

const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: activityId,
type: 'Create',
actor: sender.id,
to: recipientIds,
cc: [],
object: note
};

// Store locally
await db.notes.insert(note);

// Deliver directly to each recipient's inbox (not shared inbox!)
for (const recipientId of recipientIds) {
const recipient = await fetchActor(recipientId);
// Use personal inbox for privacy
await deliverActivity(activity, recipient.inbox, sender);
}

return { note, activity };
}

Identifying Direct Messages

Check Addressing

function isDirectMessage(note) {
const publicAddress = 'https://www.w3.org/ns/activitystreams#Public';

// No public addressing
const toPublic = note.to?.includes(publicAddress);
const ccPublic = note.cc?.includes(publicAddress);

if (toPublic || ccPublic) {
return false;
}

// No followers collection
const hasFollowers = (addresses) =>
addresses?.some(addr => addr.endsWith('/followers'));

if (hasFollowers(note.to) || hasFollowers(note.cc)) {
return false;
}

return true;
}

Mastodon's Approach

Mastodon identifies DMs as posts where all recipients are also Mentioned in tags:

function isMastodonDirectMessage(note) {
const allRecipients = [...(note.to || []), ...(note.cc || [])];
const publicAddress = 'https://www.w3.org/ns/activitystreams#Public';

// Filter out public and get actual recipients
const actualRecipients = allRecipients.filter(r =>
r !== publicAddress && !r.endsWith('/followers')
);

// Get mentioned actors
const mentionedActors = (note.tag || [])
.filter(t => t.type === 'Mention')
.map(t => t.href);

// All recipients should be mentioned
return actualRecipients.every(r => mentionedActors.includes(r));
}

Group Direct Messages

DMs can include multiple recipients:

{
"type": "Note",
"attributedTo": "https://example.com/users/alice",
"to": [
"https://server-b.example/users/bob",
"https://server-c.example/users/carol"
],
"content": "<p>Hey both of you!</p>",
"tag": [
{
"type": "Mention",
"href": "https://server-b.example/users/bob",
"name": "@bob@server-b.example"
},
{
"type": "Mention",
"href": "https://server-c.example/users/carol",
"name": "@carol@server-c.example"
}
]
}

Handling Group DMs

async function getConversation(userId, participants) {
// Sort participant IDs for consistent lookup
const sortedParticipants = [...participants, userId].sort();
const conversationKey = sortedParticipants.join(',');

// Find all DMs between these participants
const messages = await db.notes.find({
$and: [
{ 'to': { $all: participants } },
{ $or: [
{ attributedTo: userId },
{ 'to': userId }
]}
]
}).sort({ published: 1 });

return messages;
}

Delivery Considerations

Use Personal Inbox, Not Shared Inbox

For privacy, DMs should be delivered to personal inboxes:

async function deliverDirectMessage(activity, sender) {
const recipients = activity.to || [];

for (const recipientId of recipients) {
const recipient = await fetchActor(recipientId);

// Use personal inbox for DMs, never shared inbox
const inbox = recipient.inbox;

await deliverActivity(activity, inbox, sender);
}
}

Strip bto/bcc Before Delivery

The spec requires removing bto and bcc before federation:

function prepareForDelivery(activity) {
const prepared = { ...activity };
delete prepared.bto;
delete prepared.bcc;

if (prepared.object && typeof prepared.object === 'object') {
delete prepared.object.bto;
delete prepared.object.bcc;
}

return prepared;
}

Conversations View

Grouping Messages

async function getConversations(userId) {
// Get all DMs involving this user
const dms = await db.notes.find({
$and: [
{ $or: [{ attributedTo: userId }, { 'to': userId }] },
// Is a DM (no public, no followers)
{ 'to': { $not: { $regex: /Public$|\/followers$/ } } }
]
}).sort({ published: -1 });

// Group by participants
const conversations = new Map();

for (const dm of dms) {
const participants = getParticipants(dm, userId);
const key = participants.sort().join(',');

if (!conversations.has(key)) {
conversations.set(key, {
participants,
lastMessage: dm,
messages: []
});
}
conversations.get(key).messages.push(dm);
}

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

function getParticipants(note, excludeUserId) {
const all = new Set([
note.attributedTo,
...(note.to || [])
]);
all.delete(excludeUserId);
return Array.from(all);
}

Security Considerations

No End-to-End Encryption

warning

ActivityPub DMs are not end-to-end encrypted. Server administrators can read them, similar to email. They're private from other users, not from server operators.

Content Stored on Multiple Servers

DMs are stored on:

  1. The sender's server
  2. Each recipient's server

Once sent, the sender cannot guarantee deletion on remote servers.

Authentication

Always verify the sender:

async function processDirectMessage(activity, signer) {
// Verify the activity's actor matches the signer
if (activity.actor !== signer.id) {
throw new Error('Actor does not match signer');
}

// Verify the object's attributedTo matches
if (activity.object.attributedTo !== activity.actor) {
throw new Error('Attribution mismatch');
}

// Process the DM...
}

Pleroma ChatMessage Extension

Pleroma introduced a separate ChatMessage type for better DM UX:

{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://pleroma.social/litepub"
],
"type": "ChatMessage",
"id": "https://pleroma.example/chat/123",
"actor": "https://pleroma.example/users/alice",
"to": ["https://other.example/users/bob"],
"content": "Hey!"
}

Key differences:

  • Only one recipient allowed per ChatMessage
  • Cleaner conversation threading
  • Not converted to mention-style display
function isChatMessage(activity) {
return activity.object?.type === 'ChatMessage';
}

Common Issues

Accidental Public DMs

Prevent users from accidentally making DMs public:

function validateDirectMessage(note) {
const publicAddress = 'https://www.w3.org/ns/activitystreams#Public';

if (note.to?.includes(publicAddress) ||
note.cc?.includes(publicAddress)) {
throw new Error('Direct messages cannot include public addressing');
}

if ((note.to || []).length === 0) {
throw new Error('Direct messages must have at least one recipient');
}
}

Missing Mentions

Ensure all recipients are mentioned for Mastodon compatibility:

function ensureMentions(note) {
const recipientIds = new Set([...(note.to || [])]);
const mentionedIds = new Set(
(note.tag || [])
.filter(t => t.type === 'Mention')
.map(t => t.href)
);

for (const recipientId of recipientIds) {
if (!mentionedIds.has(recipientId)) {
// Add missing mention
note.tag = note.tag || [];
note.tag.push({
type: 'Mention',
href: recipientId,
name: `@${extractHandle(recipientId)}`
});
}
}
}

Reply Threading

When replying to a DM, maintain the conversation:

async function replyToDirectMessage(sender, originalNote, content) {
// Keep the same recipient list
const recipients = [
originalNote.attributedTo,
...(originalNote.to || [])
].filter(id => id !== sender.id);

return sendDirectMessage(sender, recipients, content, {
inReplyTo: originalNote.id
});
}

Next Steps