Verified against Mastodon v4.7.3 source (activitypub/tag_manager.rb, parser/status_parser.rb, activity/create.rb, status_reach_finder.rb, account.rb), ActivityPub REC §6, Pleroma AP extensions docs on 2026-10-15.
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: [mentioned users] │
│ Direct: to: [mentioned users], cc: [] │
│ │
│ No as:Public + no followers = Direct Message │
│ │
└─────────────────────────────────────────────────────────┘
These are the shapes Mastodon sends (tag_manager.rb to/cc); mentioned users are also added to cc on public, unlisted and followers-only posts. On receipt, Mastodon derives visibility the same way: as:Public in to → public, in cc → unlisted, the author's followers collection in to → followers-only, otherwise direct (status_parser.rb).
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 once per inbox (shared inbox if advertised, as Mastodon does)
const inboxes = new Set();
for (const recipientId of recipientIds) {
const recipient = await fetchActor(recipientId);
inboxes.add(recipient.endpoints?.sharedInbox || recipient.inbox);
}
for (const inbox of inboxes) {
await deliverActivity(activity, inbox, sender);
}
return { note, activity };
}
Identifying Direct Messages
Check Addressing
Compare against the author's real followers URL (from their actor document) — followers collections can live at any URL, so don't guess from a /followers suffix. Treat all three spellings of the Public collection as public:
const PUBLIC = new Set([
'https://www.w3.org/ns/activitystreams#Public',
'as:Public',
'Public',
]);
// Mirrors Mastodon's visibility rules (status_parser.rb)
function visibilityOf(note, author) {
const to = [].concat(note.to || []);
const cc = [].concat(note.cc || []);
if (to.some(a => PUBLIC.has(a))) return 'public';
if (cc.some(a => PUBLIC.has(a))) return 'unlisted';
if (author.followers && to.includes(author.followers)) return 'private';
return 'direct';
}
Mastodon's Extra Rule: Mentions
A post that comes out as direct stays a DM on Mastodon only if every local account it is addressed to is also tagged with a Mention. An addressed account without a Mention becomes a silent mention, and the post is downgraded to limited visibility — still private, but not shown as a direct message, and no notification (create.rb process_audience). So always send a Mention tag for each DM recipient.
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
Personal or Shared Inbox
Either works. Mastodon delivers every post, DMs included, to the recipient server's shared inbox when one is advertised (account.rb preferred_inbox_url); the receiving server then decides who may see it from to/cc. That means a receiving server must enforce addressing on shared-inbox deliveries — never show a delivered object to local users who aren't addressed. Delivering to a personal inbox additionally tells the receiver which account it is for: Mastodon gives that account access to the post even if it isn't addressed (it is added as a silent mention, see postprocess_audience_and_deliver in create.rb).
Strip bto/bcc Before Delivery
The spec requires removing bto and bcc before delivery (AP §6, §7.1):
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
Store the visibility computed by visibilityOf() when you ingest a note, then query on it:
async function getConversations(userId) {
// Get all DMs involving this user
const dms = await db.notes.find({
visibility: 'direct',
$or: [{ attributedTo: userId }, { to: userId }, { cc: userId }]
}).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 has a separate 1-on-1 chat type, ChatMessage (http://litepub.social/ns#ChatMessage). It is wrapped in a Create, is always private, and must have exactly one actor in to — multi-user chats go through a Group that Announces messages (Pleroma AP extensions):
{
"type": "Create",
"id": "https://pleroma.example/objects/1",
"actor": "https://pleroma.example/users/alice",
"to": ["https://other.example/users/bob"],
"object": {
"type": "ChatMessage",
"id": "https://pleroma.example/objects/2",
"attributedTo": "https://pleroma.example/users/alice",
"to": ["https://other.example/users/bob"],
"content": "Hey!",
"published": "2026-10-15T10:00:00Z"
}
}
Mastodon only accepts Note and Question (plus a few converted types such as Article) in Create, so it ignores ChatMessage entirely (activity.rb). Send ordinary DMs to non-Pleroma software.
function isChatMessage(activity) {
return activity.type === 'Create' && 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 every recipient is tagged, otherwise Mastodon downgrades the post to "limited" and doesn't notify (see above):
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