Your First ActivityPub Server
In this tutorial, we'll build a minimal but functional ActivityPub server from scratch. By the end, you'll have a server that:
- Serves an Actor profile
- Handles WebFinger discovery
- Has an inbox that can receive activities
- Can be discovered and followed by Mastodon users
Prerequisites
- Node.js 18+ installed
- Basic understanding of HTTP and REST APIs
- A domain name (or use ngrok for testing)
Project Setup
Create a new project:
mkdir my-activitypub-server
cd my-activitypub-server
npm init -y
npm install express body-parser
Step 1: Basic Server Structure
Create server.js:
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const PORT = 3000;
const DOMAIN = 'your-domain.com'; // Change this!
const USERNAME = 'alice';
// Parse JSON bodies
app.use(bodyParser.json({
type: ['application/json', 'application/activity+json', 'application/ld+json']
}));
// Store for followers (in-memory for demo)
const followers = new Set();
const inbox = [];
app.listen(PORT, () => {
console.log(`ActivityPub server running on port ${PORT}`);
});
Step 2: WebFinger Endpoint
WebFinger allows other servers to discover your actor. When someone searches for @alice@your-domain.com, their server queries:
GET /.well-known/webfinger?resource=acct:alice@your-domain.com
Add the WebFinger endpoint:
app.get('/.well-known/webfinger', (req, res) => {
const resource = req.query.resource;
if (resource !== `acct:${USERNAME}@${DOMAIN}`) {
return res.status(404).json({ error: 'User not found' });
}
res.json({
subject: `acct:${USERNAME}@${DOMAIN}`,
links: [
{
rel: 'self',
type: 'application/activity+json',
href: `https://${DOMAIN}/users/${USERNAME}`
}
]
});
});
Step 3: Actor Endpoint
The Actor endpoint returns the profile. This is what other servers fetch to learn about your user:
app.get('/users/:username', (req, res) => {
if (req.params.username !== USERNAME) {
return res.status(404).json({ error: 'User not found' });
}
res.set('Content-Type', 'application/activity+json');
res.json({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
],
type: 'Person',
id: `https://${DOMAIN}/users/${USERNAME}`,
inbox: `https://${DOMAIN}/users/${USERNAME}/inbox`,
outbox: `https://${DOMAIN}/users/${USERNAME}/outbox`,
followers: `https://${DOMAIN}/users/${USERNAME}/followers`,
following: `https://${DOMAIN}/users/${USERNAME}/following`,
preferredUsername: USERNAME,
name: 'Alice',
summary: 'A minimal ActivityPub actor',
url: `https://${DOMAIN}/@${USERNAME}`,
publicKey: {
id: `https://${DOMAIN}/users/${USERNAME}#main-key`,
owner: `https://${DOMAIN}/users/${USERNAME}`,
publicKeyPem: PUBLIC_KEY // We'll generate this next
}
});
});
Step 4: Generate Keys
ActivityPub requires RSA keys for HTTP Signatures. Generate them:
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -outform PEM -pubout -out public.pem
Load the keys in your server:
const fs = require('fs');
const crypto = require('crypto');
const PRIVATE_KEY = fs.readFileSync('./private.pem', 'utf8');
const PUBLIC_KEY = fs.readFileSync('./public.pem', 'utf8');
Step 5: Inbox Endpoint
The inbox receives activities from other servers:
app.post('/users/:username/inbox', async (req, res) => {
if (req.params.username !== USERNAME) {
return res.status(404).json({ error: 'User not found' });
}
const activity = req.body;
console.log('Received activity:', JSON.stringify(activity, null, 2));
// Store the activity
inbox.push(activity);
// Handle different activity types
switch (activity.type) {
case 'Follow':
await handleFollow(activity);
break;
case 'Undo':
if (activity.object?.type === 'Follow') {
await handleUnfollow(activity);
}
break;
case 'Create':
console.log('Received post:', activity.object?.content);
break;
default:
console.log('Unhandled activity type:', activity.type);
}
res.status(202).send('Accepted');
});
Step 6: Handle Follow Requests
When someone follows you, send an Accept activity back:
const https = require('https');
async function handleFollow(activity) {
const followerActor = activity.actor;
// Add to followers
followers.add(followerActor);
console.log(`New follower: ${followerActor}`);
// Fetch the follower's inbox
const followerInfo = await fetchActor(followerActor);
const followerInbox = followerInfo.inbox;
// Create Accept activity
const acceptActivity = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Accept',
id: `https://${DOMAIN}/activities/${Date.now()}`,
actor: `https://${DOMAIN}/users/${USERNAME}`,
object: activity
};
// Send Accept to follower's inbox
await sendActivity(followerInbox, acceptActivity);
}
async function handleUnfollow(activity) {
const followerActor = activity.actor;
followers.delete(followerActor);
console.log(`Unfollowed by: ${followerActor}`);
}
Step 7: HTTP Signatures
All outgoing requests must be signed. Here's a signing function:
function signRequest(method, url, body) {
const urlObj = new URL(url);
const date = new Date().toUTCString();
const digest = body
? `SHA-256=${crypto.createHash('sha256').update(body).digest('base64')}`
: null;
const signedHeaders = digest
? '(request-target) host date digest'
: '(request-target) host date';
const signatureString = [
`(request-target): ${method.toLowerCase()} ${urlObj.pathname}`,
`host: ${urlObj.host}`,
`date: ${date}`,
digest ? `digest: ${digest}` : null
].filter(Boolean).join('\n');
const signature = crypto.sign('sha256', Buffer.from(signatureString), PRIVATE_KEY);
const signatureBase64 = signature.toString('base64');
return {
date,
digest,
signature: `keyId="https://${DOMAIN}/users/${USERNAME}#main-key",` +
`algorithm="rsa-sha256",` +
`headers="${signedHeaders}",` +
`signature="${signatureBase64}"`
};
}
Step 8: Send Activities
Send signed activities to other servers:
async function sendActivity(inboxUrl, activity) {
const body = JSON.stringify(activity);
const headers = signRequest('POST', inboxUrl, body);
const urlObj = new URL(inboxUrl);
return new Promise((resolve, reject) => {
const req = https.request({
hostname: urlObj.hostname,
port: 443,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/activity+json',
'Date': headers.date,
'Digest': headers.digest,
'Signature': headers.signature,
'Host': urlObj.host
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
console.log(`Sent activity to ${inboxUrl}: ${res.statusCode}`);
resolve(data);
});
});
req.on('error', reject);
req.write(body);
req.end();
});
}
Step 9: Fetch Remote Actors
Fetch actor information from other servers:
async function fetchActor(actorUrl) {
return new Promise((resolve, reject) => {
const urlObj = new URL(actorUrl);
https.get({
hostname: urlObj.hostname,
path: urlObj.pathname,
headers: {
'Accept': 'application/activity+json'
}
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
}).on('error', reject);
});
}
Step 10: Collections Endpoints
Add the remaining endpoints:
// Outbox
app.get('/users/:username/outbox', (req, res) => {
res.set('Content-Type', 'application/activity+json');
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: `https://${DOMAIN}/users/${USERNAME}/outbox`,
totalItems: 0,
orderedItems: []
});
});
// Followers
app.get('/users/:username/followers', (req, res) => {
res.set('Content-Type', 'application/activity+json');
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: `https://${DOMAIN}/users/${USERNAME}/followers`,
totalItems: followers.size,
orderedItems: Array.from(followers)
});
});
// Following
app.get('/users/:username/following', (req, res) => {
res.set('Content-Type', 'application/activity+json');
res.json({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'OrderedCollection',
id: `https://${DOMAIN}/users/${USERNAME}/following`,
totalItems: 0,
orderedItems: []
});
});
Complete Server Code
Here's the complete minimal server:
// server.js - Complete minimal ActivityPub server
const express = require('express');
const bodyParser = require('body-parser');
const crypto = require('crypto');
const https = require('https');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
const DOMAIN = process.env.DOMAIN || 'your-domain.com';
const USERNAME = 'alice';
// Load RSA keys
const PRIVATE_KEY = fs.readFileSync('./private.pem', 'utf8');
const PUBLIC_KEY = fs.readFileSync('./public.pem', 'utf8');
// In-memory storage
const followers = new Set();
const inbox = [];
app.use(bodyParser.json({
type: ['application/json', 'application/activity+json', 'application/ld+json']
}));
// ... (all the endpoints from above)
app.listen(PORT, () => {
console.log(`ActivityPub server running at https://${DOMAIN}`);
});
Testing Your Server
1. Use ngrok for Local Testing
ngrok http 3000
Update DOMAIN to your ngrok URL (without https://).
2. Test WebFinger
curl "https://your-ngrok-url/.well-known/webfinger?resource=acct:alice@your-ngrok-url"
3. Test Actor Endpoint
curl -H "Accept: application/activity+json" "https://your-ngrok-url/users/alice"
4. Search from Mastodon
On any Mastodon instance, search for @alice@your-ngrok-url. You should see your actor!
Common Issues
"Could not fetch profile"
- Check that your server returns correct
Content-Type: application/activity+json - Ensure HTTPS is working (use ngrok)
- Verify WebFinger returns the correct actor URL
"Follow not working"
- Check HTTP Signatures are correct
- Ensure your server's clock is synchronized
- Look at server logs for signature verification errors
"Activities not being delivered"
- Verify the inbox URL is correct
- Check that you're signing requests
- Ensure the
Dateheader is within a few minutes of current time
Next Steps
You now have a working ActivityPub server! To make it production-ready:
- Add database storage - Replace in-memory storage
- Implement posting - Create and deliver posts
- Handle more activities - Likes, boosts, replies
- HTTP Signatures deep dive - Understand the cryptography