Skip to main content

Verified against ActivityPub REC §7.5–7.7; Mastodon v4.7.3 source (activity/follow.rb, activity/accept.rb, activity/undo.rb, follow_request.rb, tag_manager.rb, context_helper.rb) on 2026-10-15.

Following and Followers

The follow mechanism is fundamental to ActivityPub. It establishes relationships between actors and determines how content flows through the network. This guide covers implementing Follow, Accept, Reject, and managing follower collections.

The Follow Flow​

FOLLOW FLOW1Alice sends Follow to Bob's inbox2Bob's server validates the request3Bob's server decides: Accept or Reject4Bob sends Accept/Reject5Alice updates following list6Posts now delivered to AliceFollow Relationship EstablishedAlice now receives Bob's public postsand followers-only content

Sending a Follow Request​

The Follow Activity​

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://alice.example/activities/follow-123",
"type": "Follow",
"actor": "https://alice.example/users/alice",
"object": "https://bob.example/users/bob"
}

Implementation​

import crypto from 'node:crypto';
const idOf = (v) => (typeof v === 'string' ? v : v?.id);

async function sendFollow(follower, followee) {
// followee: the actor document, fetched SSRF-safely, whose id you checked
const follow = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${crypto.randomUUID()}`,
type: 'Follow',
actor: follower.id,
object: followee.id
};

// Store the pending request BEFORE delivering: the Accept can arrive
// before the delivery call returns.
await db.followRequests.insert({
id: follow.id,
follower: follower.id,
followee: followee.id,
followeeFollowers: followee.followers, // to recognise their followers-only posts
status: 'pending',
createdAt: new Date()
});

await deliver(followee.inbox, follow); // signed delivery, see Sending Activities

return follow;
}

Receiving a Follow Request​

When your server receives a Follow activity, you must decide whether to accept or reject it.

Processing the Follow​

async function processFollow(activity, signer) {
// Validate: actor must match signer (parseActivityFrom already enforces this)
if (activity.actor !== signer.id) {
throw new Error('Actor does not match signer');
}

// Find the local user being followed (object may be a URL or an embedded actor)
const followee = await db.actors.findOne({ id: idOf(activity.object) });
if (!followee) {
return; // Not our user, ignore
}

// Keep the Follow: an Undo may later reference it only by id
await db.receivedFollows.upsert({ id: activity.id, actor: activity.actor, object: followee.id });

// Check if already following
const existing = await db.followers.findOne({
actor: activity.object,
follower: activity.actor
});
if (existing) {
// Already following, send Accept anyway (idempotent)
return acceptFollow(activity, followee);
}

// Blocked follower (or blocked domain): reject
const blocked = await isBlocked(activity.actor, followee.id);
if (blocked) {
return rejectFollow(activity, followee);
}

// Auto-accept or queue for manual approval
if (followee.manuallyApprovesFollowers) {
await queueFollowRequest(activity, followee);
} else {
await acceptFollow(activity, followee);
}
}

Mastodon does the same: a repeated Follow from an existing follower gets another Accept, and a Follow from an account or domain the target blocks (or to an account that has moved) gets a Reject (follow.rb).

Manual Approval Flow​

async function queueFollowRequest(activity, followee) {
await db.pendingFollows.insert({
activityId: activity.id,
follower: activity.actor,
followee: followee.id,
createdAt: new Date()
});

// Notify the user they have a pending follow request
await createNotification(followee.id, 'follow_request', {
from: activity.actor
});
}

// Called when user approves
async function approveFollowRequest(requestId) {
const request = await db.pendingFollows.findOne({ _id: requestId });
const followee = await db.actors.findOne({ id: request.followee });

// Reconstruct the original Follow activity
const followActivity = {
id: request.activityId,
type: 'Follow',
actor: request.follower,
object: request.followee
};

await acceptFollow(followActivity, followee);
await db.pendingFollows.remove({ _id: requestId });
}

Sending Accept​

The Accept activity tells the follower that their request was approved.

Accept Activity Format​

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://bob.example/activities/accept-456",
"type": "Accept",
"actor": "https://bob.example/users/bob",
"object": {
"id": "https://alice.example/activities/follow-123",
"type": "Follow",
"actor": "https://alice.example/users/alice",
"object": "https://bob.example/users/bob"
}
}

Implementation​

async function acceptFollow(followActivity, followee) {
// Fetch the follower ourselves (SSRF-safe) and check its id: never take
// an inbox URL from the activity.
const { json: followerActor } = await safeFetchJson(followActivity.actor);
if (followerActor.id !== followActivity.actor || !followerActor.inbox) return;

// Add to followers (upsert: a repeated Follow is re-accepted), keeping
// the inboxes we'll deliver to
await db.followers.upsert({
actor: followee.id,
follower: followerActor.id,
inbox: followerActor.inbox,
sharedInbox: followerActor.endpoints?.sharedInbox,
createdAt: new Date()
});

const accept = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${crypto.randomUUID()}`,
type: 'Accept',
actor: followee.id,
object: followActivity
};
await deliver(followerActor.inbox, accept);

return accept;
}

:::tip Object Identifiers Some implementations include the full Follow activity as the object, while others only include the Follow activity's id. Both are valid, but embed the full activity: Mastodon first matches the Accept's object id against its pending requests, and only falls back to the embedded Follow's object when that fails (accept.rb). :::

Sending Reject​

Use Reject to decline a follow request.

Reject Activity Format​

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://bob.example/activities/reject-789",
"type": "Reject",
"actor": "https://bob.example/users/bob",
"object": {
"id": "https://alice.example/activities/follow-123",
"type": "Follow",
"actor": "https://alice.example/users/alice",
"object": "https://bob.example/users/bob"
}
}

Implementation​

async function rejectFollow(followActivity, followee) {
const reject = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${crypto.randomUUID()}`,
type: 'Reject',
actor: followee.id,
object: followActivity
};

const { json: followerActor } = await safeFetchJson(followActivity.actor);
if (followerActor.id === followActivity.actor && followerActor.inbox) {
await deliver(followerActor.inbox, reject);
}

return reject;
}

:::note Privacy Consideration The spec doesn't require a Reject. You can leave a request pending instead, so the requester can't tell they were blocked. Mastodon does send a Reject when the follower is blocked. :::

Receiving Accept/Reject​

When your server receives an Accept or Reject for a Follow you sent:

async function processAccept(activity, signer) {
const followActivity = activity.object;

// Handle both inline object and ID reference
const followId = typeof followActivity === 'string'
? followActivity
: followActivity.id;

// Find the pending follow request
const pending = await db.followRequests.findOne({
$or: [
{ id: followId },
{ followee: signer.id, status: 'pending' }
]
});

if (!pending) {
return; // Unknown follow request
}

// Verify the Accept is from the person we followed
if (signer.id !== pending.followee) {
throw new Error('Accept not from followee');
}

// Update our following list
await db.following.insert({
actor: pending.follower,
following: pending.followee,
followersCollection: pending.followeeFollowers,
createdAt: new Date()
});

// Mark request as accepted
await db.followRequests.update(
{ id: pending.id },
{ $set: { status: 'accepted' } }
);
}

async function processReject(activity, signer) {
const followActivity = activity.object;
const followId = typeof followActivity === 'string'
? followActivity
: followActivity.id;

// Remove the pending follow request
await db.followRequests.remove({
$or: [
{ id: followId },
{ followee: signer.id, status: 'pending' }
]
});
}

Unfollowing (Undo Follow)​

To unfollow, send an Undo activity wrapping the original Follow.

Undo Follow Format​

{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://alice.example/activities/undo-123",
"type": "Undo",
"actor": "https://alice.example/users/alice",
"object": {
"id": "https://alice.example/activities/follow-123",
"type": "Follow",
"actor": "https://alice.example/users/alice",
"object": "https://bob.example/users/bob"
}
}

Sending Undo​

async function sendUnfollow(follower, followee) {
// Embed the original Follow, with its id, so the receiver can match it
const original = await db.followRequests.findOne({ follower: follower.id, followee: followee.id });
const undo = {
'@context': 'https://www.w3.org/ns/activitystreams',
id: `https://${config.domain}/activities/${crypto.randomUUID()}`,
type: 'Undo',
actor: follower.id,
object: {
...(original && { id: original.id }),
type: 'Follow',
actor: follower.id,
object: followee.id
}
};

// Remove from local following list
await db.following.remove({
actor: follower.id,
following: followee.id
});

// Notify the remote server (followee: actor document fetched earlier)
await deliver(followee.inbox, undo);
}

Receiving Undo Follow​

async function processUndoFollow(activity, signer) {
// The Follow may be embedded or just referenced by id
const followActivity = typeof activity.object === 'string'
? await db.receivedFollows.findOne({ id: activity.object })
: activity.object;
if (!followActivity) return;

// Verify actor owns the original Follow
if (idOf(followActivity.actor) !== signer.id) {
throw new Error('Cannot undo follow from another actor');
}

// Remove from followers
await db.followers.remove({
actor: idOf(followActivity.object),
follower: signer.id
});
}

Followers Collection​

Expose followers as an ActivityPub Collection:

app.get('/users/:username/followers', async (req, res) => {
const actor = await db.actors.findOne({
preferredUsername: req.params.username
});

if (!actor) {
return res.status(404).send();
}

const page = parseInt(req.query.page) || null;
const pageSize = 20;

if (page) {
// Return a page of followers
const followers = await db.followers
.find({ actor: actor.id })
.sort({ createdAt: -1 })
.skip((page - 1) * pageSize)
.limit(pageSize);

const totalCount = await db.followers.count({ actor: actor.id });
const totalPages = Math.ceil(totalCount / pageSize);

return res.type('application/activity+json').json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollectionPage',
id: `${actor.id}/followers?page=${page}`,
partOf: `${actor.id}/followers`,
orderedItems: followers.map(f => f.follower),
...(page < totalPages && {
next: `${actor.id}/followers?page=${page + 1}`
}),
...(page > 1 && {
prev: `${actor.id}/followers?page=${page - 1}`
})
});
}

// Return collection summary
const totalItems = await db.followers.count({ actor: actor.id });

res.type('application/activity+json').json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: `${actor.id}/followers`,
totalItems,
first: `${actor.id}/followers?page=1`
});
});

Locked Accounts​

For accounts requiring manual follow approval:

{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers"
}
],
"type": "Person",
"id": "https://example.com/users/private",
"manuallyApprovesFollowers": true
}

Common Issues​

Duplicate Follows​

Handle duplicate Follow activities gracefully:

if (existing) {
// Already following - send Accept again (idempotent)
return acceptFollow(activity, followee);
}

Follow ID Not Dereferenceable​

Mastodon gives Follow activities ids like https://mastodon.example/payloads/<uuid> (tag_manager.rb generate_uri_for) and has no route that serves them. Store the Follows you receive, and never rely on fetching a Follow by its id.

Race Conditions​

The Accept can arrive before your delivery call has even returned. Store the pending Follow before you deliver it (as sendFollow above does), so the Accept always finds it.

Next Steps​