Skip to main content

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:

  1. A WebFinger response mapping acct:alice@example.com to the actor URL (WebFinger Implementation)
  2. The actor document at that URL, served as application/activity+json
  3. An inbox that verifies every POST (Handling Incoming Activities)
  4. An outbox and followers / following collections
  5. 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):

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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:

CheckSource (Mastodon v4.7.3)
The response is application/activity+json, or application/ld+json with the ActivityStreams profilejson_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/activitystreamsjson_ld_helper.rb supported_context?
type is one of Application, Group, Organization, Person, Servicefetch_remote_actor_service.rb
preferredUsername (or an FEP-2c59 webfinger property) is presentfetch_remote_actor_service.rb
inbox is presentprocess_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 id string; it does not resolve a relative "#me" against the document URL. Serve absolute ids.
  • outbox is not required by Mastodon, but the ActivityPub spec says actors MUST have both inbox and outbox (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​

PropertyMeaning in Mastodon
name, summaryDisplay name and bio (HTML)
urlLink to the profile page
icon, imageAvatar and header (Image with url)
manuallyApprovesFollowersLocked account: follows need approval
discoverableListed in the profile directory
indexablePublic posts may be full-text indexed
featured, featuredTagsPinned posts and featured hashtags collections
attachment with PropertyValueProfile metadata fields
alsoKnownAs, movedToAccount migration (Account Migration)
published, memorial, suspendedAccount 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​