Verified against GoToSocial 0.22.2 and Mastodon 4.7.3 (federation tests in CI), Node.js 24; code tested in examples/first-server on 2026-10-15.
Your First ActivityPub Server
This tutorial walks through a small ActivityPub server with one account. By the end you will have a server that:
- can be found with WebFinger (
@alice@your.domain) - serves an actor with a public key
- verifies every request that reaches its inbox
- accepts followers, and delivers posts to them with signed requests
:::tip This code is tested against a real server
Every code block on this page is imported from examples/first-server. Its unit tests run on every change, and a federation test starts GoToSocial 0.22.2 in Docker, follows this server from it, publishes a post and checks that the post arrives in the follower's home timeline. A second test does the same with Mastodon 4.7.3. Both run on changes to the code and weekly (workflow).
:::
It is teaching code, written to be read. For a production server, start from a maintained framework such as Fedify and use this page to understand what it does for you.
Prerequisites
- Node.js 22 or later (CI uses 24)
- A public hostname with HTTPS on port 443. Federation needs it: other servers build URLs from your hostname and only speak https. For local development, a tunnel such as
fedify tunnel(see Testing & debugging) gives your machine a temporary public https address.
Get the code
The server uses no framework and only one library, structured-headers, which comes in through the request-verification module it imports.
git clone https://github.com/socialdocs/socialdocs.org.git
cd socialdocs.org/examples/verify-requests && npm ci
cd ../first-server
DOMAIN=your.domain USERNAME=alice ADMIN_TOKEN=change-me DATA_DIR=./data npm start
The server listens on port 8443 by default. Set PORT=443, TLS_CERT and TLS_KEY to terminate TLS itself, or put a reverse proxy in front of it.
Step 1: Configuration and URLs
Everything about the server's identity comes from its domain and username. Keep every public URL in one place: an actor's URLs are permanent, and other servers store them.
// Settings come from the environment so the same code runs locally, in
// tests and in the Docker federation test.
export function loadConfig(env = process.env) {
const domain = env.DOMAIN;
if (!domain) throw new Error('Set DOMAIN to the public hostname of this server (e.g. social.example)');
return {
domain,
username: env.USERNAME || 'alice',
displayName: env.DISPLAY_NAME || 'Alice',
port: Number(env.PORT || 8443),
tlsCert: env.TLS_CERT, // PEM paths; omit them behind a TLS-terminating proxy
tlsKey: env.TLS_KEY,
dataDir: env.DATA_DIR, // keys and followers persist here when set
adminToken: env.ADMIN_TOKEN, // enables POST /admin/notes when set
// Lets the server fetch and deliver to private IPs. ONLY for local
// development and the Docker integration test, never in production.
allowPrivateAddresses: env.ALLOW_PRIVATE_ADDRESSES === 'true',
};
}
/** All public URLs of the server's single actor. */
export function urlsFor({ domain, username }) {
const base = `https://${domain}`;
const actor = `${base}/users/${username}`;
return {
base,
actor,
keyId: `${actor}#main-key`,
inbox: `${actor}/inbox`,
outbox: `${actor}/outbox`,
followers: `${actor}/followers`,
following: `${actor}/following`,
sharedInbox: `${base}/inbox`,
};
}
Step 2: Keys and storage
Each actor needs a key pair. Other servers fetch the public key from the actor document to check the requests you sign. This store generates an RSA key once, then keeps it with the followers and posts in a JSON file.
// A tiny store: the actor's key pair, its followers and its notes.
// It persists to DATA_DIR as JSON when one is configured. Swap it for a
// database in a real server; the rest of the code only uses these methods.
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
export function openStore(dataDir) {
const file = dataDir && path.join(dataDir, 'state.json');
let state = file && fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null;
if (!state) {
// ActivityPub doesn't mandate a key type, but RSA is what every
// server can verify today (Ed25519 support is still spreading).
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
state = {
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }),
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }),
followers: {}, // actor id -> { inbox, sharedInbox }
notes: [], // newest last
};
}
const save = () => {
if (!file) return;
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(file, JSON.stringify(state, null, 2), { mode: 0o600 });
};
save();
return {
publicKeyPem: state.publicKeyPem,
privateKey: crypto.createPrivateKey(state.privateKeyPem),
followers: () => Object.entries(state.followers).map(([id, f]) => ({ id, ...f })),
addFollower(id, { inbox, sharedInbox }) { state.followers[id] = { inbox, sharedInbox }; save(); },
removeFollower(id) { delete state.followers[id]; save(); },
notes: () => state.notes,
addNote(note) { state.notes.push(note); save(); },
note: (id) => state.notes.find((n) => n.id === id),
};
}
Step 3: The documents other servers fetch
- WebFinger turns
acct:alice@your.domaininto the actor URL. It's how a user on another server finds you when they search for@alice@your.domain. See the WebFinger guide. - The actor advertises the inbox, outbox and collections, and the
publicKey. The key'sownermust be the actor's ownid. - Notes are posted as plain text and escaped into HTML. Never insert user or remote text into HTML unescaped.
- Accept embeds the original Follow so the follower's server can match it to the request it sent.
// The JSON documents other servers fetch from us.
import crypto from 'node:crypto';
const AS = 'https://www.w3.org/ns/activitystreams';
const PUBLIC = `${AS}#Public`;
/** WebFinger (RFC 7033): maps acct:alice@domain to the actor URL. */
export function webfinger(cfg, urls) {
return {
subject: `acct:${cfg.username}@${cfg.domain}`,
aliases: [urls.actor],
links: [{ rel: 'self', type: 'application/activity+json', href: urls.actor }],
};
}
/** The actor. publicKey lets others verify the requests we sign. */
export function actor(cfg, urls, store) {
return {
'@context': [AS, 'https://w3id.org/security/v1'],
id: urls.actor,
type: 'Person',
preferredUsername: cfg.username,
name: cfg.displayName,
inbox: urls.inbox,
outbox: urls.outbox,
followers: urls.followers,
following: urls.following,
endpoints: { sharedInbox: urls.sharedInbox },
manuallyApprovesFollowers: false,
publicKey: { id: urls.keyId, owner: urls.actor, publicKeyPem: store.publicKeyPem },
};
}
/** A collection that only reveals its size. */
export function countOnlyCollection(id, totalItems) {
return { '@context': AS, id, type: 'OrderedCollection', totalItems };
}
const escapeHtml = (s) => s.replace(/[&<>"']/g,
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
/** A public Note. Plain text in, safe HTML out. */
export function note(urls, text, published = new Date()) {
return {
id: `${urls.base}/notes/${crypto.randomUUID()}`,
type: 'Note',
attributedTo: urls.actor,
content: `<p>${escapeHtml(text)}</p>`,
published: published.toISOString(),
to: [PUBLIC],
cc: [urls.followers],
};
}
/** Wrap a Note in the Create we deliver; addressing mirrors the Note. */
export function create(urls, noteDoc) {
return {
'@context': AS,
id: `${noteDoc.id}/activity`,
type: 'Create',
actor: urls.actor,
published: noteDoc.published,
to: noteDoc.to,
cc: noteDoc.cc,
object: noteDoc,
};
}
/** Accept a Follow. The Follow is embedded so the receiver can match it. */
export function accept(urls, follow) {
return {
'@context': AS,
id: `${urls.base}/accepts/${crypto.randomUUID()}`,
type: 'Accept',
actor: urls.actor,
object: follow,
};
}
/** The outbox: our public Creates, newest first. */
export function outbox(urls, notes) {
const items = [...notes].reverse().map((n) => create(urls, n));
return { '@context': AS, id: urls.outbox, type: 'OrderedCollection', totalItems: items.length, orderedItems: items };
}
Step 4: Routing
Every ActivityPub response uses Content-Type: application/activity+json. Mastodon ignores documents served as plain application/json. WebFinger uses application/jrd+json, and RFC 7033 requires it to allow cross-origin requests.
The handler also defines fetchJson, used for every remote fetch. It signs its GET requests, because GoToSocial (always) and Mastodon (in "secure mode") refuse unsigned fetches of actors and keys. It also refuses private network addresses, because remote servers choose the URLs it fetches.
POST /admin/notes publishes a post. It only exists when ADMIN_TOKEN is set, and the token is compared in constant time.
// HTTP routing. Plain node:http, no framework, so every step is visible.
import crypto from 'node:crypto';
import { KeyStore, safeFetchJson, signCavage } from '../../verify-requests/src/index.js';
import { urlsFor } from './config.js';
import * as doc from './documents.js';
import { makeInbox } from './inbox.js';
import { makeDeliver } from './deliver.js';
const AP_TYPE = 'application/activity+json';
const MAX_BODY = 1024 * 1024;
function send(res, status, body, type = AP_TYPE, extra = {}) {
res.writeHead(status, { 'content-type': type, vary: 'Accept', ...extra });
res.end(body == null ? undefined : JSON.stringify(body));
}
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY) throw Object.assign(new Error('Body too large'), { status: 413 });
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
const sameToken = (given, expected) => {
const a = Buffer.from(given || '');
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
};
/**
* Build the request handler. `fetchJson` and `deliver` can be replaced in
* tests; by default they sign with our key and refuse private addresses.
*/
export function createApp({ cfg, store, fetchJson, deliver, log = console }) {
const urls = urlsFor(cfg);
const sign = (opts) => signCavage({ ...opts, keyId: urls.keyId, privateKey: store.privateKey });
// Signed GETs: servers in "secure mode" (GoToSocial always, Mastodon with
// AUTHORIZED_FETCH) refuse unsigned requests for actors and keys.
fetchJson ??= (url) => safeFetchJson(url, {
signRequest: ({ url: u }) => sign({ method: 'GET', url: u }),
allowPrivateAddresses: cfg.allowPrivateAddresses,
});
deliver ??= makeDeliver({
keyId: urls.keyId, privateKey: store.privateKey, allowPrivateAddresses: cfg.allowPrivateAddresses,
});
const keyStore = new KeyStore(fetchJson);
const handleInbox = makeInbox({ cfg, urls, store, keyStore, fetchJson, deliver, log });
const userPath = `/users/${cfg.username}`;
async function publish(text) {
const note = doc.note(urls, text);
store.addNote(note);
const activity = doc.create(urls, note);
// One delivery per server when it has a shared inbox.
const inboxes = [...new Set(store.followers().map((f) => f.sharedInbox || f.inbox))];
const results = await Promise.all(inboxes.map((inbox) => deliver(inbox, activity)
.then((status) => ({ inbox, status }), (err) => ({ inbox, error: err.message }))));
return { note, deliveries: results };
}
return async function handler(req, res) {
const url = new URL(req.url, urls.base);
try {
if (req.method === 'GET' && url.pathname === '/.well-known/webfinger') {
if (url.searchParams.get('resource') !== `acct:${cfg.username}@${cfg.domain}`) return send(res, 404, null);
return send(res, 200, doc.webfinger(cfg, urls), 'application/jrd+json',
{ 'access-control-allow-origin': '*' }); // RFC 7033 §5: CORS MUST be allowed
}
if (req.method === 'GET' && url.pathname === userPath) return send(res, 200, doc.actor(cfg, urls, store));
if (req.method === 'GET' && url.pathname === `${userPath}/outbox`) return send(res, 200, doc.outbox(urls, store.notes()));
if (req.method === 'GET' && url.pathname === `${userPath}/followers`) {
return send(res, 200, doc.countOnlyCollection(urls.followers, store.followers().length));
}
if (req.method === 'GET' && url.pathname === `${userPath}/following`) {
return send(res, 200, doc.countOnlyCollection(urls.following, 0));
}
if (req.method === 'GET' && url.pathname.startsWith('/notes/')) {
const note = store.note(`${urls.base}${url.pathname}`);
return note ? send(res, 200, { '@context': 'https://www.w3.org/ns/activitystreams', ...note }) : send(res, 404, null);
}
if (req.method === 'POST' && (url.pathname === `${userPath}/inbox` || url.pathname === '/inbox')) {
const rawBody = await readBody(req);
const [status, body] = await handleInbox({ method: req.method, path: req.url, headers: req.headers, rawBody });
return send(res, status, body, 'application/json');
}
if (req.method === 'POST' && url.pathname === '/admin/notes' && cfg.adminToken) {
if (!sameToken(req.headers.authorization, `Bearer ${cfg.adminToken}`)) return send(res, 401, null);
const { text } = JSON.parse((await readBody(req)).toString('utf8'));
if (typeof text !== 'string' || !text) return send(res, 400, { error: 'text required' }, 'application/json');
return send(res, 201, await publish(text), 'application/json');
}
return send(res, 404, null, 'text/plain');
} catch (err) {
log.error?.(err);
return send(res, err.status || 500, null, 'text/plain');
}
};
}
Step 5: The inbox
Anyone on the internet can POST to an inbox, so the order of checks matters:
- Verify the HTTP signature over the raw request body, at the full URL that was signed. This step is
verifyRequest. It accepts draft-cavage and RFC 9421 signatures, and resolves both Mastodon-style (#main-key) and GoToSocial-style (/main-key) key ids. - Require that the signer is
activity.actor. A valid signature from one actor says nothing about an activity that claims to come from another. - Apply the origin rules, for example that an Undo can only undo the sender's own activity.
- Answer quickly. The server replies
202 Accepted, then does the slow work (fetching the follower, sending the Accept) in the background.
When a Follow arrives, the server fetches the follower's actor document itself and delivers the Accept to the inbox listed there, never to a URL taken from the activity.
// Handling POSTs to the inbox.
//
// Order matters: verify the signature over the raw body, require that the
// signer IS activity.actor, apply the origin rules, and only then act.
import {
verifyRequest, parseActivityFrom, authorizeActivity,
SignatureError, KeyResolutionError, AuthorizationError, UnsafeUrlError,
} from '../../verify-requests/src/index.js';
import { accept } from './documents.js';
const idOf = (v) => (typeof v === 'string' ? v : v?.id);
export function makeInbox({ cfg, urls, store, keyStore, fetchJson, deliver, log = console }) {
async function onFollow(activity) {
if (idOf(activity.object) !== urls.actor) return; // not a follow of us
// Fetch the follower ourselves: never take an inbox URL from the activity.
const { json: follower } = await fetchJson(activity.actor);
if (follower.id !== activity.actor || !follower.inbox) throw new Error('Unusable follower actor');
store.addFollower(follower.id, {
inbox: follower.inbox,
sharedInbox: follower.endpoints?.sharedInbox,
});
const status = await deliver(follower.inbox, accept(urls, activity));
log.info?.(`Accepted follow from ${follower.id} (HTTP ${status})`);
}
function onUndo(activity) {
const inner = activity.object;
if (inner?.type === 'Follow' && idOf(inner.object) === urls.actor) {
store.removeFollower(activity.actor); // authorizeActivity checked inner.actor
}
}
/** Returns [status, body]. Work that talks to other servers runs after we answer. */
return async function handleInbox({ method, path, headers, rawBody }) {
let activity;
try {
const signer = await verifyRequest({
method,
url: `https://${cfg.domain}${path}`, // the URL the sender signed, query string included
headers,
rawBody,
}, { keyStore });
activity = parseActivityFrom(signer, rawBody);
authorizeActivity(activity);
} catch (err) {
if (err instanceof SignatureError || err instanceof KeyResolutionError ||
err instanceof AuthorizationError || err instanceof UnsafeUrlError ||
err instanceof SyntaxError) {
log.warn?.(`Rejected inbox POST: ${err.message}`);
return [401, { error: err.message }];
}
// Couldn't reach the sender's key: ask them to retry later.
log.warn?.(`Could not verify inbox POST: ${err.message}`);
return [503, { error: 'Temporarily unable to verify' }];
}
const work = activity.type === 'Follow' ? () => onFollow(activity)
: activity.type === 'Undo' ? () => onUndo(activity)
: null; // a real server would handle Create, Like, Delete, ... here
if (work) {
setImmediate(() => Promise.resolve().then(work)
.catch((err) => log.error?.(`${activity.type} from ${activity.actor} failed: ${err.message}`)));
}
return [202, null];
};
}
Step 6: Signed delivery
Delivering an activity means POSTing it to the recipient's inbox, signed with your key. The receiver fetches your actor, finds the key named in keyId, and checks the signature.
This code signs with draft-cavage first, because most servers verify it. If the receiver answers 400 or 401 it retries once with RFC 9421, which is how Mastodon 4.7 does it (see HTTP signatures). Inbox URLs come from remote documents, so delivery applies the same private-address checks as fetching.
When publishing, the server sends one copy per remote server where possible, using that server's shared inbox.
// Signed delivery of activities to remote inboxes.
//
// Inbox URLs come from remote actor documents, so they are attacker-chosen:
// resolve the host, refuse private addresses and connect to exactly the
// address we checked (the same SSRF rules as for fetching keys).
import https from 'node:https';
import { signCavage, signRfc9421, resolveAddress } from '../../verify-requests/src/index.js';
function post({ url, address, headers, body, timeoutMs }) {
return new Promise((resolve, reject) => {
const req = https.request(url, {
method: 'POST',
headers: { ...headers, 'content-length': body.length },
timeout: timeoutMs,
lookup: (_host, opts, cb) => (opts && opts.all
? cb(null, [{ address: address.address, family: address.family }])
: cb(null, address.address, address.family)),
}, (res) => {
res.resume(); // we only need the status
res.on('end', () => resolve(res.statusCode));
});
req.on('timeout', () => req.destroy(new Error(`Timed out delivering to ${url}`)));
req.on('error', reject);
req.end(body);
});
}
/**
* Deliver one activity to one inbox. Signs with draft-cavage first (what
* most servers verify) and retries once with RFC 9421 when the receiver
* answers 400 or 401, which is how Mastodon 4.7 "double-knocks".
* Returns the final HTTP status.
*/
export function makeDeliver({ keyId, privateKey, allowPrivateAddresses = false, timeoutMs = 10_000, send = post }) {
return async function deliver(inbox, activity) {
const url = new URL(inbox);
if (url.protocol !== 'https:') throw new Error(`Refusing to deliver to ${url.protocol} inbox`);
const address = await resolveAddress(url.hostname, { allowPrivateAddresses });
const body = Buffer.from(JSON.stringify(activity));
const base = {
'content-type': 'application/activity+json',
'user-agent': 'socialdocs-first-server/1.0 (+https://socialdocs.org)',
};
const status = await send({
url, address, body, timeoutMs,
headers: { ...base, ...signCavage({ url: url.href, body, keyId, privateKey }) },
});
if (status !== 400 && status !== 401) return status;
return send({
url, address, body, timeoutMs,
headers: { ...base, ...signRfc9421({ url: url.href, body, keyId, privateKey }) },
});
};
}
Step 7: Start it
// Start the server: `DOMAIN=social.example node src/main.js`
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import { loadConfig } from './config.js';
import { openStore } from './store.js';
import { createApp } from './app.js';
const cfg = loadConfig();
const store = openStore(cfg.dataDir);
const handler = createApp({ cfg, store });
// Federation needs https on the default port. Terminate TLS here, or put a
// reverse proxy in front and run plain http behind it.
const server = cfg.tlsCert
? https.createServer({ cert: fs.readFileSync(cfg.tlsCert), key: fs.readFileSync(cfg.tlsKey) }, handler)
: http.createServer(handler);
server.listen(cfg.port, () => {
console.log(`@${cfg.username}@${cfg.domain} listening on port ${cfg.port}`);
});
Check it from another machine:
curl "https://your.domain/.well-known/webfinger?resource=acct:alice@your.domain"
curl -H "Accept: application/activity+json" https://your.domain/users/alice
Then search for @alice@your.domain from an account on another server and follow it. To publish a post to your followers:
curl -X POST https://your.domain/admin/notes \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"text": "Hello, fediverse!"}'
The response lists each delivery and the status code the receiving server returned.
Test it against a real server
The unit tests (npm test in examples/first-server) cover WebFinger, the actor, the inbox's rejection of unsigned and mismatched requests, Follow → Accept, Undo, publishing, and the delivery retry.
The federation test, integration/run.sh, needs Docker and OpenSSL. It:
- creates a throwaway certificate authority and a certificate for
ap.testandgts.test - starts this server as
ap.testand GoToSocial 0.22.2 asgts.teston a private Docker network, both on https port 443 - creates a GoToSocial user with its admin CLI and gets an API token through GoToSocial's documented OAuth flow
- resolves
@tester@ap.testfrom GoToSocial, follows it, and waits until the follow is accepted - publishes a post on
ap.testand waits until it appears in the GoToSocial user's home timeline
Two settings make this work on a private network, and both are for testing only:
ALLOW_PRIVATE_ADDRESSES=trueon our server- GoToSocial's
http-client.allow-ipslimited to the test subnet
Never enable either on a server reachable from the internet.
The Mastodon test (integration/run-mastodon.sh in the same directory) runs the same steps against Mastodon 4.7.3: web and Sidekiq from the official image, with Postgres and Redis, behind Caddy for TLS. It creates the user with tootctl accounts create and mints an API token with rails runner. It also marks the user as signed in, because Mastodon only fans posts out to home feeds of users active in the last 7 days (USER_ACTIVE_DAYS). On the private network it sets ALLOWED_PRIVATE_ADDRESSES to the test subnet and points SSL_CERT_FILE at the throwaway CA — again, test-only settings.
Common problems
Other servers can't find you. Check that WebFinger answers for the exact acct: you searched for, and that the actor is served with Content-Type: application/activity+json over https on port 443.
Follows stay pending. Your Accept wasn't delivered or wasn't accepted. Check the status code your server got back. A 401 usually means the receiver couldn't fetch your key, or your signature doesn't cover what it expects.
Your inbox rejects a server. Your server's logs give the reason. A 503 means the sender's key couldn't be fetched (the sender will retry). A clock that's off by more than an hour also breaks signatures: Mastodon accepts a signature for about 65 minutes.
What's missing for production
This server is deliberately small. A real one also needs:
- a database, and a job queue that retries failed deliveries with backoff (see Delivery)
- handling for incoming Create, Update, Delete, Like and Announce (see Handling incoming activities)
- followers and outbox collections with paging
- rate limiting, and a plan for key rotation
- checks against more servers than GoToSocial and Mastodon (see Mastodon compatibility and the other platform guides)
Next steps
- Verifying incoming requests: the verification code this server imports
- The origin model: which servers may say what about which objects
- Sending activities
- HTTP signatures in 2026