Verified against Mastodon v4.7.3 source (activity/like.rb, activity/announce.rb, activity/undo.rb, tag_manager.rb, status_policy.rb, likes_controller.rb, shares_controller.rb), ActivityPub REC §5.7-5.8, §7.8-7.11 on 2026-10-15.
Likes and Shares
Engagement activities allow users to interact with content beyond just viewing it. In ActivityPub, Like represents favouriting/liking content, while Announce represents sharing/boosting/reblogging. This guide covers implementing both.
Like Activity
The Like activity indicates appreciation for content without sharing it.
Like Format
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activities/like-123",
"type": "Like",
"actor": "https://example.com/users/alice",
"object": "https://other.example/notes/456"
}
Sending a Like
async function sendLike(actor, objectId) {
// Check if already liked
const existing = await db.likes.findOne({
actor: actor.id,
object: objectId
});
if (existing) {
return existing; // Already liked
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${uuid()}`,
type: 'Like',
actor: actor.id,
object: objectId
};
// Store locally
await db.likes.insert({
id: activity.id,
actor: actor.id,
object: objectId,
createdAt: new Date()
});
// Fetch the object to get the author's inbox
const object = await fetchObject(objectId);
const author = await fetchActor(object.attributedTo);
// Deliver to the object's author
await deliverActivity(activity, author.inbox, actor);
return activity;
}
Receiving a Like
async function processLike(activity, signer) {
// Validate actor matches signer
if (activity.actor !== signer.id) {
throw new Error('Actor mismatch');
}
const objectId = typeof activity.object === 'string'
? activity.object
: activity.object.id;
// Find the local object
const object = await db.notes.findOne({ id: objectId });
if (!object) {
return; // Not our object
}
// Check if already liked
const existing = await db.likes.findOne({
actor: activity.actor,
object: objectId
});
if (existing) {
return; // Already recorded
}
// Record the like
await db.likes.insert({
id: activity.id,
actor: activity.actor,
object: objectId,
createdAt: new Date()
});
// Mastodon treats every Like as a plain favourite — it ignores any
// emoji `content`/`_misskey_reaction` (see Custom Emoji)
await createNotification(object.attributedTo, 'favourite', {
actor: activity.actor,
object: objectId
});
}
Announce Activity (Boost/Share)
The Announce activity shares content to your followers—known as "boosting" or "reblogging."
Announce Format
Mastodon sends a public boost to as:Public, with the boosted post's author and the booster's followers in cc (tag_manager.rb):
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activities/announce-123",
"type": "Announce",
"actor": "https://example.com/users/alice",
"published": "2024-01-15T10:30:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [
"https://other.example/users/bob",
"https://example.com/users/alice/followers"
],
"object": "https://other.example/notes/456"
}
Sending an Announce
async function sendAnnounce(actor, objectId) {
// Check if already announced
const existing = await db.announces.findOne({
actor: actor.id,
object: objectId
});
if (existing) {
return existing;
}
// Fetch the original object
const object = await fetchObject(objectId);
if (!canBoost(object, actor)) {
throw new Error('This post cannot be boosted');
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${uuid()}`,
type: 'Announce',
actor: actor.id,
published: new Date().toISOString(),
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: [
object.attributedTo, // Notify the original author
actor.followers
],
object: objectId
};
// Store locally
await db.announces.insert({
id: activity.id,
actor: actor.id,
object: objectId,
createdAt: new Date()
});
// Add to actor's outbox
await db.outbox.insert({
actor: actor.id,
activity: activity,
createdAt: new Date()
});
// Deliver to followers and original author
await deliverToRecipients(activity, actor);
return activity;
}
Receiving an Announce
The announced object usually belongs to a third server, so an embedded copy proves nothing: fetch it from its own origin. Mastodon also drops boosts nobody local cares about (the booster isn't followed locally, it didn't arrive via a relay, and the post isn't local) and only accepts boosts of public/unlisted posts, or of the booster's own posts (announce.rb):
async function processAnnounce(activity, signer) {
if (activity.actor !== signer.id) {
throw new Error('Actor mismatch');
}
const objectId = typeof activity.object === 'string'
? activity.object
: activity.object?.id;
if (!objectId) return;
let object = await db.notes.findOne({ id: objectId });
if (!object) {
if (!(await isFollowedLocally(activity.actor))) return; // nobody here cares
try {
// Ignore any embedded copy; fetch from the object's origin and make
// sure the document really lives there (see the Origin Model page)
object = await safeFetchObject(objectId);
if (new URL(object.id).origin !== new URL(objectId).origin) return;
} catch (error) {
console.log('Could not fetch announced object:', objectId);
return;
}
if (!canBoost(object, { id: activity.actor })) return;
await db.notes.insert(object);
}
// Record the announce
await db.announces.insert({
id: activity.id,
actor: activity.actor,
object: objectId,
createdAt: new Date()
});
// If the announced object is ours, notify the author
// (compare origins — a startsWith() prefix check also matches example.com.evil)
if (new URL(object.id).origin === `https://${config.domain}`) {
await createNotification(object.attributedTo, 'reblog', {
actor: activity.actor,
object: objectId
});
}
// Add to timelines for followers of the announcer
await addToFollowerTimelines(activity.actor, activity);
}
Undo Like
To unlike, send an Undo wrapping the Like:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activities/undo-like-123",
"type": "Undo",
"actor": "https://example.com/users/alice",
"object": {
"id": "https://example.com/activities/like-123",
"type": "Like",
"actor": "https://example.com/users/alice",
"object": "https://other.example/notes/456"
}
}
Implementation
async function sendUndoLike(actor, objectId) {
const like = await db.likes.findOne({
actor: actor.id,
object: objectId
});
if (!like) {
return; // Nothing to undo
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${uuid()}`,
type: 'Undo',
actor: actor.id,
object: {
id: like.id,
type: 'Like',
actor: actor.id,
object: objectId
}
};
// Remove local record
await db.likes.remove({ _id: like._id });
// Notify the object's author
const object = await fetchObject(objectId);
const author = await fetchActor(object.attributedTo);
await deliverActivity(activity, author.inbox, actor);
}
Receiving Undo Like
The wrapped Like may be embedded or just its id, so look it up by id when you can, and only ever remove the signer's own likes:
async function processUndoLike(activity, signer) {
if (activity.actor !== signer.id) {
throw new Error('Actor mismatch');
}
const likeActivity = activity.object;
const likeId = typeof likeActivity === 'string' ? likeActivity : likeActivity?.id;
// Prefer the stored Like (works when only the id is sent)
const stored = likeId && await db.likes.findOne({ id: likeId, actor: signer.id });
if (stored) {
await db.likes.remove({ id: stored.id });
return;
}
// Fall back to the embedded Like's object
const objectId = typeof likeActivity?.object === 'string'
? likeActivity.object
: likeActivity?.object?.id;
if (objectId) {
await db.likes.remove({ actor: signer.id, object: objectId });
}
}
Undo Announce
To unboost, send an Undo wrapping the Announce:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/activities/undo-announce-123",
"type": "Undo",
"actor": "https://example.com/users/alice",
"object": {
"id": "https://example.com/activities/announce-123",
"type": "Announce",
"actor": "https://example.com/users/alice",
"object": "https://other.example/notes/456"
}
}
Implementation
async function sendUndoAnnounce(actor, objectId) {
const announce = await db.announces.findOne({
actor: actor.id,
object: objectId
});
if (!announce) {
return;
}
const activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${uuid()}`,
type: 'Undo',
actor: actor.id,
to: ['https://www.w3.org/ns/activitystreams#Public'],
cc: [actor.followers],
object: {
id: announce.id,
type: 'Announce',
actor: actor.id,
object: objectId
}
};
// Remove local record
await db.announces.remove({ _id: announce._id });
// Deliver to followers
await deliverToRecipients(activity, actor);
}
Likes Collection
Expose likes as a Collection on objects:
app.get('/notes/:id/likes', async (req, res) => {
const noteId = `https://${config.domain}/notes/${req.params.id}`;
const totalItems = await db.likes.count({ object: noteId });
res.type('application/activity+json').json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: `${noteId}/likes`,
totalItems
// Mastodon's likes/shares collections also carry only totalItems
});
});
Shares Collection
Expose shares/announces as a Collection:
app.get('/notes/:id/shares', async (req, res) => {
const noteId = `https://${config.domain}/notes/${req.params.id}`;
const totalItems = await db.announces.count({ object: noteId });
res.type('application/activity+json').json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
id: `${noteId}/shares`,
totalItems
});
});
Displaying Counts
async function getEngagementCounts(noteId) {
const [likes, announces, replies] = await Promise.all([
db.likes.count({ object: noteId }),
db.announces.count({ object: noteId }),
db.notes.count({ inReplyTo: noteId })
]);
return { likes, announces, replies };
}
Privacy Considerations
Who Can See Likes?
Mastodon's likes and shares collections expose only totalItems, never who liked or boosted (likes_controller.rb). Whether you list individual likers is a privacy decision for your software.
Boost Restrictions
Mastodon lets anyone boost public and unlisted posts, and lets authors boost their own followers-only posts; direct and limited posts can't be boosted (status_policy.rb). Also respect a post's interactionPolicy.canAnnounce where present (see Quote Posts and Interaction Policies).
const PUBLIC = new Set(['https://www.w3.org/ns/activitystreams#Public', 'as:Public', 'Public']);
function canBoost(object, booster) {
const addressed = [].concat(object.to || [], object.cc || []);
if (addressed.some(a => PUBLIC.has(a))) return true; // public or unlisted
return object.attributedTo === booster.id; // self-boost
}
Common Issues
Stale Counts
Federation means counts may be outdated. Consider:
async function refreshCounts(noteId) {
// Only refresh periodically
const note = await db.notes.findOne({ id: noteId });
const lastRefresh = note.countsRefreshedAt || 0;
const hourAgo = Date.now() - 60 * 60 * 1000;
if (lastRefresh > hourAgo) {
return; // Recently refreshed
}
// Fetch fresh data from source
// This is implementation-specific
}
Duplicate Activities
Handle duplicate Like/Announce gracefully:
const existing = await db.likes.findOne({
actor: activity.actor,
object: objectId
});
if (existing) {
return; // Idempotent - just ignore
}
Announce vs Create
Don't confuse boosted content with original content:
function isBoost(activity) {
return activity.type === 'Announce';
}
async function getOriginalAuthor(activity) {
if (isBoost(activity)) {
// The real author is the announced object's attributedTo — read it from
// the copy you fetched from its origin, not from an embedded object
const object = await db.notes.findOne({ id: activity.object?.id ?? activity.object });
return object?.attributedTo;
}
return activity.actor;
}
Next Steps
- Direct Messages - Private messaging
- Mentions and Hashtags - Tagging
- Mastodon Compatibility - Implementation details
- Announce sequence diagram - External reference for the boost exchange