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
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:
- The sender's server
- 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
- Mentions and Hashtags - Tagging users
- Content Moderation - Handling abuse
- Authentication and Security - Security model