What is ActivityPub?
ActivityPub is a decentralized social networking protocol developed by the W3C Social Web Working Group. It defines two distinct layers: the Client-to-Server API (C2S) for direct user interaction with their chosen server, and the Server-to-Server API (S2S) for federation between servers across the open web. At its core, ActivityPub models social interactions as Activities — discrete actions like "Create", "Like", "Follow", or "Announce" — performed by Actors (users, groups, or bots) on Objects (notes, articles, images, or other actors).
The protocol uses Activity Streams 2.0 vocabulary serialized as JSON-LD, which provides a rich, extensible way to describe social graph interactions. Every actor has an inbox for receiving activities and an outbox for publishing them. When you post a status update, your server creates a Create activity in your outbox and delivers it to the inboxes of your followers across any compliant server anywhere on the internet.
Why ActivityPub Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →The significance of ActivityPub extends far beyond technical curiosity. It represents the foundation of the fediverse — a network of interconnected but independently operated servers that collectively form a resilient, censorship-resistant social web. Platforms like Mastodon, Pixelfed, PeerTube, and WriteFreely all speak ActivityPub, enabling seamless cross-service interaction without a central authority owning user relationships or data.
- Platform independence: Users choose their server, or self-host, without losing access to the wider network. If a server shuts down, users migrate their identity elsewhere.
- No algorithmic manipulation: The protocol delivers content directly between actors with no intermediary optimizing for engagement. Chronological timelines are the default.
- Extensibility: JSON-LD allows domain-specific extensions without breaking core interoperability. Implementations ignore unrecognized properties rather than rejecting messages.
- Real ownership: Social graph data lives on the user's home server. Following relationships, likes, and posts belong to the user, not to a platform.
- Interoperability mandate: The W3C recommendation status means implementers build for the open web, not for a proprietary ecosystem.
Core Concepts: Actors, Activities, and Objects
Before writing code, you must internalize the data model. Every entity in ActivityPub is addressable by an IRI (Internationalized Resource Identifier) that resolves to a JSON-LD document. There are three fundamental types:
Actors
An Actor represents a user, bot, or service that can perform activities. Actor objects contain properties like inbox, outbox, followers, following, preferredUsername, and publicKey. Here is a minimal actor document:
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1"
],
"id": "https://example.social/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice Example",
"summary": "Hello fediverse!",
"inbox": "https://example.social/users/alice/inbox",
"outbox": "https://example.social/users/alice/outbox",
"followers": "https://example.social/users/alice/followers",
"following": "https://example.social/users/alice/following",
"publicKey": {
"id": "https://example.social/users/alice#main-key",
"owner": "https://example.social/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----"
}
}
Objects
Objects are the targets of activities — notes, articles, images, videos, or even other actors. The most common object in microblogging is Note, typically containing content, attributedTo, and sometimes inReplyTo for threaded conversations:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.social/users/alice/statuses/abc123",
"type": "Note",
"attributedTo": "https://example.social/users/alice",
"content": "Hello world, this is my first post on the fediverse!",
"published": "2025-01-15T14:30:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.social/users/alice/followers"]
}
Activities
Activities wrap objects with a verb describing what happened. A Create activity announces a new object; a Like activity expresses appreciation; a Follow activity requests a subscription. The activity itself is what gets delivered to inboxes:
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.social/users/alice/activities/create/xyz789",
"type": "Create",
"actor": "https://example.social/users/alice",
"object": {
"type": "Note",
"id": "https://example.social/users/alice/statuses/abc123",
"attributedTo": "https://example.social/users/alice",
"content": "Hello world, this is my first post!",
"published": "2025-01-15T14:30:00Z"
},
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.social/users/alice/followers"]
}
Setting Up Your First ActivityPub Server
Let's build a minimal ActivityPub server in Node.js with Express. This implementation will handle webfinger discovery, serve actor documents, and accept deliveries to inboxes. We'll start with the scaffolding:
// server.js
const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');
const jsonld = require('jsonld');
const app = express();
// Store actors and their inboxes in memory for simplicity
const actors = new Map();
const inboxes = new Map();
// Parse incoming JSON-LD with raw body available for signature verification
app.use(bodyParser.json({
type: ['application/json', 'application/ld+json',
'application/activity+json'],
verify: (req, res, buf) => {
req.rawBody = buf.toString();
}
}));
const PORT = process.env.PORT || 3000;
const DOMAIN = process.env.DOMAIN || `localhost:${PORT}`;
const BASE_URL = `https://${DOMAIN}`;
app.listen(PORT, () => {
console.log(`ActivityPub server running on ${BASE_URL}`);
// Seed an example actor for testing
initializeActor('alice');
});
Implementing Webfinger Discovery
When a remote server wants to find your user alice@example.social, it performs a webfinger lookup. Your server must expose a /.well-known/webfinger endpoint that accepts a resource query parameter and returns a JSON document linking to the actor's ActivityPub profile. This is the first step in federation:
// Webfinger endpoint
app.get('/.well-known/webfinger', (req, res) => {
const resource = req.query.resource;
if (!resource) {
return res.status(400).json({ error: 'Missing resource parameter' });
}
// Parse acct:alice@example.social format
const match = resource.match(/^acct:(.+)@(.+)$/);
if (!match) {
return res.status(400).json({ error: 'Invalid resource format' });
}
const username = match[1];
const domain = match[2];
// Validate domain matches our server
if (domain !== DOMAIN) {
return res.status(404).json({ error: 'Domain not served here' });
}
const actor = actors.get(username);
if (!actor) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
subject: `acct:${username}@${DOMAIN}`,
aliases: [actor.id],
links: [
{
rel: 'self',
type: 'application/activity+json',
href: actor.id
}
]
});
});
Serving Actor Documents
When a federated server dereferences the actor IRI, it must receive a complete ActivityPub actor document with application/activity+json content type. Include security context and a public key for HTTP signature verification:
function initializeActor(username) {
const actorId = `${BASE_URL}/users/${username}`;
const keyPair = crypto.generateKeyPairSync('rsa', {
modulusLength: 4096,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
const actor = {
id: actorId,
type: 'Person',
preferredUsername: username,
name: username.charAt(0).toUpperCase() + username.slice(1),
summary: 'A friendly fediverse user',
inbox: `${actorId}/inbox`,
outbox: `${actorId}/outbox`,
followers: `${actorId}/followers`,
following: `${actorId}/following`,
publicKey: {
id: `${actorId}#main-key`,
owner: actorId,
publicKeyPem: keyPair.publicKey
},
privateKeyPem: keyPair.privateKey // stored server-side, never exposed
};
actors.set(username, actor);
inboxes.set(username, []);
return actor;
}
// Serve actor document with proper content negotiation
app.get('/users/:username', async (req, res) => {
const actor = actors.get(req.params.username);
if (!actor) return res.status(404).json({ error: 'Actor not found' });
const acceptHeader = req.headers.accept || '';
if (acceptHeader.includes('application/activity+json') ||
acceptHeader.includes('application/ld+json') ||
acceptHeader.includes('*/*')) {
// Never expose private key in actor document
const { privateKeyPem, ...publicActor } = actor;
res.set('Content-Type', 'application/activity+json');
res.json(publicActor);
} else {
// For browser requests, redirect to a human-friendly profile page
res.redirect(`/profile/${req.params.username}`);
}
});
Handling Inbox Delivery
The inbox is the most critical endpoint. It accepts POST requests with activities from both local clients (C2S) and remote servers (S2S). Every incoming activity must be verified — either by session authentication for local users or by HTTP signature verification for federated requests. Here's a robust inbox handler:
app.post('/users/:username/inbox', async (req, res) => {
const username = req.params.username;
const actor = actors.get(username);
if (!actor) {
return res.status(404).json({ error: 'Actor not found' });
}
let activity;
try {
activity = req.body;
} catch (err) {
return res.status(400).json({ error: 'Invalid JSON body' });
}
// Determine if this is a local or federated request
const isLocalRequest = req.headers['x-local-auth'] === 'valid';
if (!isLocalRequest) {
// Verify HTTP signature for federated requests
const verified = await verifyHttpSignature(req, actor);
if (!verified) {
return res.status(401).json({ error: 'Signature verification failed' });
}
}
// Store activity in inbox collection
const inbox = inboxes.get(username);
inbox.push({
activity: activity,
receivedAt: new Date().toISOString(),
deliveredBy: isLocalRequest ? 'c2s' : 's2s'
});
// Process side effects based on activity type
await processInboxActivity(username, activity);
// Always return 202 Accepted per spec
res.status(202).json({ status: 'accepted' });
});
// Side effect processing for various activity types
async function processInboxActivity(username, activity) {
const actor = actors.get(username);
const activityType = activity.type;
switch (activityType) {
case 'Follow':
// Auto-accept follows for simplicity; real implementations queue this
console.log(`${username} received a Follow from ${activity.actor}`);
const acceptActivity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Accept',
actor: actor.id,
object: activity,
to: [activity.actor]
};
// In production, deliver this Accept back to the follower's inbox
break;
case 'Create':
console.log(`${username} received a Create activity:`,
activity.object?.content);
break;
case 'Like':
console.log(`${username} received a Like on object:`,
activity.object);
break;
case 'Announce':
console.log(`${username} received an Announce (boost) of:`,
activity.object);
break;
case 'Undo':
console.log(`${username} received an Undo for:`,
activity.object?.type);
break;
case 'Delete':
console.log(`${username} received a Delete for:`,
activity.object);
break;
default:
console.log(`${username} received unknown activity type:`,
activityType);
}
}
HTTP Signatures for Authentication
Federation security relies on HTTP Signatures (draft-cavage-http-signatures). When a remote server delivers an activity, it signs the request with its actor's private key. Your server verifies by fetching the remote actor's public key and checking the signature. Here's a production-ready verification function:
const axios = require('axios');
const cryptoLib = require('crypto');
async function verifyHttpSignature(req, localActor) {
try {
const signatureHeader = req.headers['signature'];
if (!signatureHeader) {
console.log('Missing Signature header');
return false;
}
// Parse the Signature header into key-value pairs
const signatureParts = {};
signatureHeader.split(',').forEach(part => {
const eqIndex = part.indexOf('=');
if (eqIndex === -1) return;
const key = part.slice(0, eqIndex).trim();
let value = part.slice(eqIndex + 1).trim();
// Strip surrounding quotes
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
signatureParts[key] = value;
});
const keyId = signatureParts.keyId;
const algorithm = signatureParts.algorithm || 'rsa-sha256';
const signature = Buffer.from(signatureParts.signature, 'base64');
const headers = signatureParts.headers || '(request-target) host date';
if (!keyId) {
console.log('Missing keyId in Signature header');
return false;
}
// Fetch the signing actor's public key
// keyId format: https://remote.example/users/bob#main-key
const keyOwnerUrl = keyId.split('#')[0];
let remoteActor;
try {
const response = await axios.get(keyOwnerUrl, {
headers: { 'Accept': 'application/activity+json' },
timeout: 5000
});
remoteActor = response.data;
} catch (err) {
console.log(`Failed to fetch remote actor: ${err.message}`);
return false;
}
const publicKeyPem = remoteActor.publicKey?.publicKeyPem;
if (!publicKeyPem) {
console.log('Remote actor has no publicKeyPem');
return false;
}
// Construct the signing string from the specified headers
const signingLines = [];
const headerList = headers.split(' ');
for (const headerName of headerList) {
if (headerName === '(request-target)') {
const targetPath = req.originalUrl || req.url;
signingLines.push(
`(request-target): ${req.method.toLowerCase()} ${targetPath}`
);
} else if (headerName === 'host') {
signingLines.push(`host: ${req.headers.host}`);
} else if (headerName === 'date') {
signingLines.push(`date: ${req.headers.date || ''}`);
} else if (headerName === 'digest') {
signingLines.push(
`digest: ${req.headers.digest || ''}`
);
} else if (headerName === 'content-type') {
signingLines.push(
`content-type: ${req.headers['content-type'] || ''}`
);
}
}
const signingString = signingLines.join('\n');
// Verify the signature
const verifier = cryptoLib.createVerify('SHA256');
verifier.update(signingString);
verifier.end();
const isValid = verifier.verify(
publicKeyPem,
signature,
'base64'
);
if (!isValid) {
console.log('Cryptographic signature verification failed');
return false;
}
// Optional: check Date header freshness (within 30 seconds)
if (req.headers.date) {
const requestDate = new Date(req.headers.date).getTime();
const now = Date.now();
const skew = Math.abs(now - requestDate) / 1000;
if (skew > 30) {
console.log(`Date skew too large: ${skew}s`);
return false;
}
}
return true;
} catch (err) {
console.error('Signature verification error:', err.message);
return false;
}
}
Implementing the Outbox and Client-to-Server API
The C2S API allows authenticated users to create activities that get stored in their outbox and federated to followers. Here's the outbox endpoint and delivery logic:
// Client-to-Server: Post to outbox
app.post('/users/:username/outbox', async (req, res) => {
const username = req.params.username;
const actor = actors.get(username);
if (!actor) {
return res.status(404).json({ error: 'Actor not found' });
}
// In production, authenticate with OAuth2 or session tokens
const isAuthenticated = req.headers['authorization'] === 'Bearer valid-token';
if (!isAuthenticated) {
return res.status(403).json({ error: 'Authentication required' });
}
const activity = req.body;
// Validate basic activity structure
if (!activity.type) {
return res.status(400).json({ error: 'Activity must have a type' });
}
// Assign an ID and actor if not present
if (!activity.id) {
const activityId = `${actor.id}/activities/${generateId()}`;
activity.id = activityId;
}
if (!activity.actor) {
activity.actor = actor.id;
}
// Ensure @context is present
if (!activity['@context']) {
activity['@context'] = 'https://www.w3.org/ns/activitystreams';
}
// Handle Create activities: the object gets its own ID
if (activity.type === 'Create' && activity.object) {
if (!activity.object.id) {
activity.object.id =
`${actor.id}/objects/${generateId()}`;
}
if (!activity.object.attributedTo) {
activity.object.attributedTo = actor.id;
}
}
// Store in outbox collection
const outboxCollection = outboxes.get(username);
outboxCollection.push(activity);
// Deliver to followers and mentioned recipients
await deliverToRecipients(actor, activity);
res.status(201).json(activity);
});
// Outbox read endpoint (ordered collection)
app.get('/users/:username/outbox', (req, res) => {
const actor = actors.get(req.params.username);
if (!actor) return res.status(404).json({ error: 'Not found' });
const items = outboxes.get(req.params.username) || [];
const page = items.slice(-20); // newest 20 items
res.set('Content-Type', 'application/activity+json');
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: `${actor.id}/outbox`,
totalItems: items.length,
orderedItems: page
});
});
Federated Delivery to Remote Inboxes
When a local user creates a post, your server must deliver it to every follower's inbox — including followers on completely different servers. This requires HTTP POST with signature signing:
async function deliverToRecipients(actor, activity) {
// Determine recipient inboxes from to, cc, and followers
const recipientUris = new Set();
// Collect explicit recipients from to and cc fields
for (const field of ['to', 'cc']) {
const recipients = activity[field] || [];
for (const recipient of recipients) {
if (recipient === 'https://www.w3.org/ns/activitystreams#Public') {
// Public delivery means all followers
const followersList = followers.get(actor.id) || [];
followersList.forEach(f => recipientUris.add(f));
} else if (recipient.startsWith('http')) {
recipientUris.add(recipient);
}
}
}
// Resolve actor IRIs to inbox IRIs
const inboxUrls = [];
for (const uri of recipientUris) {
if (uri.includes('/inbox')) {
inboxUrls.push(uri);
} else {
// Dereference actor to find their inbox
try {
const response = await axios.get(uri, {
headers: { 'Accept': 'application/activity+json' },
timeout: 5000
});
if (response.data.inbox) {
inboxUrls.push(response.data.inbox);
}
} catch (err) {
console.log(`Failed to resolve inbox for ${uri}: ${err.message}`);
}
}
}
// Deduplicate
const uniqueInboxes = [...new Set(inboxUrls)];
// Deliver to each inbox (with retry logic in production)
for (const inboxUrl of uniqueInboxes) {
try {
await signAndPost(actor, inboxUrl, activity);
console.log(`Delivered ${activity.type} to ${inboxUrl}`);
} catch (err) {
console.error(`Delivery failed to ${inboxUrl}: ${err.message}`);
// Queue for retry in production
}
}
}
async function signAndPost(actor, inboxUrl, activity) {
const body = JSON.stringify(activity);
const crypto = require('crypto');
// Create Digest header per specification
const digest = crypto
.createHash('sha256')
.update(body)
.digest('base64');
const digestHeader = `SHA-256=${digest}`;
const date = new Date().toUTCString();
const targetUrl = new URL(inboxUrl);
const targetPath = targetUrl.pathname + targetUrl.search;
// Build signing string
const signingLines = [
`(request-target): post ${targetPath}`,
`host: ${targetUrl.host}`,
`date: ${date}`,
`digest: ${digestHeader}`,
`content-type: application/activity+json`
];
const signingString = signingLines.join('\n');
// Sign with actor's private key
const signer = crypto.createSign('sha256');
signer.update(signingString);
signer.end();
const signature = signer.sign(actor.privateKeyPem, 'base64');
const signatureHeader = [
`keyId="${actor.publicKey.id}"`,
`algorithm="rsa-sha256"`,
`headers="(request-target) host date digest content-type"`,
`signature="${signature}"`
].join(',');
const response = await axios.post(inboxUrl, activity, {
headers: {
'Content-Type': 'application/activity+json',
'Date': date,
'Digest': digestHeader,
'Signature': signatureHeader,
'Host': targetUrl.host
},
timeout: 10000
});
return response;
}
Managing Followers and Following Collections
Social graph management is central to federation. When a Follow activity arrives in an inbox, your server should update the follower collection and send an Accept back. Here's how to handle collections:
// Follower collection endpoint
app.get('/users/:username/followers', (req, res) => {
const actor = actors.get(req.params.username);
if (!actor) return res.status(404).json({ error: 'Not found' });
const followerList = followers.get(actor.id) || [];
res.set('Content-Type', 'application/activity+json');
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: `${actor.id}/followers`,
totalItems: followerList.length,
orderedItems: followerList // In production, paginate this
});
});
// Enhanced inbox processing for Follow activities
async function processFollowActivity(localUsername, activity) {
const localActor = actors.get(localUsername);
const followerId = activity.actor;
// Add to followers collection
if (!followers.has(localActor.id)) {
followers.set(localActor.id, []);
}
const followerList = followers.get(localActor.id);
if (!followerList.includes(followerId)) {
followerList.push(followerId);
}
// Send Accept activity back to the follower's inbox
const acceptActivity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Accept',
id: `${localActor.id}/activities/${generateId()}`,
actor: localActor.id,
object: {
type: 'Follow',
actor: followerId,
object: localActor.id
},
to: [followerId]
};
// Resolve follower's inbox
try {
const response = await axios.get(followerId, {
headers: { 'Accept': 'application/activity+json' }
});
const followerInbox = response.data.inbox;
await signAndPost(localActor, followerInbox, acceptActivity);
} catch (err) {
console.error(`Failed to deliver Accept to ${followerId}:`, err.message);
}
}
Content Negotiation and JSON-LD
ActivityPub mandates proper content negotiation. Servers must respond with application/activity+json when that type is requested, and may fall back to application/ld+json. The Accept header drives this decision. Additionally, JSON-LD compaction can reduce payload sizes while preserving linked data semantics:
// Utility: compact JSON-LD for efficient delivery
async function compactActivity(activity) {
const context = {
'@context': [
'https://www.w3.org/ns/activitystreams',
{
'ostatus': 'http://ostatus.org#',
'discoverable': 'ostatus:discoverable',
'sensitive': 'as:sensitive',
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers'
}
]
};
try {
const compacted = await jsonld.compact(
activity,
context['@context']
);
return compacted;
} catch (err) {
// If compaction fails, return original with context
return {
...activity,
'@context': 'https://www.w3.org/ns/activitystreams'
};
}
}
// Content negotiation middleware
function contentNegotiation(req, res, next) {
const accept = req.headers.accept || '*/*';
if (accept.includes('application/activity+json') ||
accept.includes('application/ld+json') ||
accept.includes('*/*')) {
res.set('Content-Type', 'application/activity+json');
res.set('Vary', 'Accept');
return next();
}
// HTML fallback for browser visitors
res.set('Content-Type', 'text/html');
res.send(`This is an ActivityPub endpoint.
Please use a compatible client.
Best Practices for ActivityPub Implementations
1. Embrace Idempotency and Deduplication
Activities carry unique IDs. Your inbox handler must deduplicate deliveries — the same activity may arrive multiple times due to federation retries or multiple delivery paths. Store delivered activity IDs and skip reprocessing:
const deliveredActivityIds = new Set();
async function deduplicateInbox(username, activity) {
const activityId = activity.id;
if (activityId && deliveredActivityIds.has(activityId)) {
console.log(`Duplicate activity ${activityId} — skipping`);
return false; // already processed
}
if (activityId) {
deliveredActivityIds.add(activityId);
}
return true; // proceed with processing
}
2. Implement Robust Error Handling and Retry Queues
Federation is inherently unreliable — remote servers go down, network partitions occur, and DNS fails. Never assume a delivery will succeed on the first attempt. Implement exponential backoff with a persistent queue:
const deliveryQueue = []; // In production, use Redis or a database
async function queueDelivery(actor, inboxUrl, activity, attempt = 1) {
deliveryQueue.push({
actor: actor.id,
inboxUrl,
activity,
attempt,
nextAttempt: Date.now() + (Math.pow(2, attempt) * 1000),
maxAttempts: 7
});
}
async function processDeliveryQueue() {
const now = Date.now();
const readyJobs = deliveryQueue.filter(j => j.nextAttempt <= now);
for (const job of readyJobs) {
try {
await signAndPost(
actors.get(job.actor.split('/').pop()),
job.inboxUrl,
job.activity
);
// Success: remove from queue
const index = deliveryQueue.indexOf(job);
deliveryQueue.splice(index, 1);
} catch (err) {
if (job.attempt >= job.maxAttempts) {
console.error(`Permanent failure delivering to ${job.inboxUrl}`);
const index = deliveryQueue.indexOf(job);
deliveryQueue.splice(index, 1);
} else {
job.attempt += 1;
job.nextAttempt = Date.now() +
(Math.pow(2, job.attempt) * 1000 * 60); // backoff in minutes
}
}
}
}
// Run queue processor every 30 seconds
setInterval(processDeliveryQueue, 30000);
3. Validate and Sanitize Incoming Content
Remote servers are untrusted. Activities may contain malicious HTML, oversized payloads, or malformed JSON-LD. Always sanitize content fields, limit payload size, and validate JSON-LD structure before processing:
const MAX_PAYLOAD_SIZE = 256 * 1024; // 256KB
function sanitizeActivity(activity) {
// Strip dangerous HTML from Note content
if (activity.object && activity.object.type === 'Note') {
const content = activity.object.content || '';
// Allow only basic formatting tags
const sanitized = content
.replace(/