Verified against ActivityPub REC §4.1, RFC 7033, Mastodon v4.7.3 source (fetch_remote_actor_service.rb, process_account_service.rb, json_ld_helper.rb) and docs.joinmastodon.org/spec/activitypub; code tested in examples/first-server on 2026-10-15.
Building an Actor
An actor is the JSON document that represents an account: other servers fetch it to learn where to deliver (inbox), who follows it (followers) and which key signs its requests (publicKey). This page explains what goes into that document and what Mastodon checks before it accepts it.
:::tip The whole server, tested This page focuses on the actor document. For a complete server (routes, inbox, delivery) whose code is tested against a real GoToSocial instance in CI, follow Your First ActivityPub Server. The code below is imported from the same tested module. :::
The pieces
An account that other servers can find, follow and receive posts from needs:
- A WebFinger response mapping
acct:alice@example.comto the actor URL (WebFinger Implementation) - The actor document at that URL, served as
application/activity+json - An inbox that verifies every POST (Handling Incoming Activities)
- An outbox and followers / following collections
- A key pair: the public half is published in the actor, the private half signs everything you send (Sending Activities)
The actor document
This is the tested code that builds the WebFinger response and the actor (from examples/first-server/src/documents.js):
// 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 };
}
Served for alice on social.example, the actor looks like this:
{
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
"id": "https://social.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"name": "Alice",
"inbox": "https://social.example/users/alice/inbox",
"outbox": "https://social.example/users/alice/outbox",
"followers": "https://social.example/users/alice/followers",
"following": "https://social.example/users/alice/following",
"endpoints": { "sharedInbox": "https://social.example/inbox" },
"manuallyApprovesFollowers": false,
"publicKey": {
"id": "https://social.example/users/alice#main-key",
"owner": "https://social.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n"
}
}
Only advertise a sharedInbox if you actually process deliveries there: Mastodon sends one copy of a public post per server to the shared inbox when you have one, so a stub that drops everything loses those posts.
What Mastodon checks
When Mastodon resolves your account it applies these checks, in order. Each one is a hard failure:
| Check | Source (Mastodon v4.7.3) |
|---|---|
The response is application/activity+json, or application/ld+json with the ActivityStreams profile | json_ld_helper.rb |
The document's id is exactly the URL that was fetched (json['id'] == uri) | json_ld_helper.rb fetch_resource |
@context includes https://www.w3.org/ns/activitystreams | json_ld_helper.rb supported_context? |
type is one of Application, Group, Organization, Person, Service | fetch_remote_actor_service.rb |
preferredUsername (or an FEP-2c59 webfinger property) is present | fetch_remote_actor_service.rb |
inbox is present | process_account_service.rb |
A WebFinger lookup of acct:preferredUsername@actor-host returns a self link whose href is the actor id ("loops back") | process_account_service.rb check_webfinger! |
The username matches [a-z0-9_]+([.-]+[a-z0-9_]+)* (case-insensitive) | account.rb USERNAME_RE |
Two consequences catch people out:
- Relative or mismatched ids fail. Mastodon compares the raw
idstring; it does not resolve a relative"#me"against the document URL. Serve absolute ids. outboxis not required by Mastodon, but the ActivityPub spec says actors MUST have bothinboxandoutbox(AP §4.1), and other software reads it. Serve it.
type: "Service" or "Application" marks the account as a bot in Mastodon (Mastodon profile properties).
Optional properties Mastodon uses
| Property | Meaning in Mastodon |
|---|---|
name, summary | Display name and bio (HTML) |
url | Link to the profile page |
icon, image | Avatar and header (Image with url) |
manuallyApprovesFollowers | Locked account: follows need approval |
discoverable | Listed in the profile directory |
indexable | Public posts may be full-text indexed |
featured, featuredTags | Pinned posts and featured hashtags collections |
attachment with PropertyValue | Profile metadata fields |
alsoKnownAs, movedTo | Account migration (Account Migration) |
published, memorial, suspended | Account creation date and flags |
All from Mastodon's profile properties list. Extension terms such as discoverable and featured live in the toot: namespace, so declare them in your @context (see Mastodon Compatibility).
Keys
The actor publishes the public key that verifies your signed requests. RSA keys in publicKey.publicKeyPem work everywhere; Mastodon 4.7 also reads Ed25519 keys published through FEP-521a assertionMethod. See Keys and Identity for formats, rotation and which servers accept what.
Generate an RSA key once and keep the private half secret:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out private.pem
openssl pkey -in private.pem -pubout -out public.pem
Test it
# WebFinger: must return a self link to the actor URL
curl -s "https://social.example/.well-known/webfinger?resource=acct:alice@social.example"
# The actor: check Content-Type and that "id" is exactly this URL
curl -si -H 'Accept: application/activity+json' https://social.example/users/alice
# Fetch it the way a remote server would
npx @fedify/cli lookup @alice@social.example
Then search for @alice@social.example from a Mastodon account. If it isn't found, see Troubleshooting by Symptom.
What's Next
- Handling Incoming Activities - Verify and process what arrives
- Sending Activities - Sign and deliver
- Following and Followers - Accept followers