Skip to main content

Mastodon Compatibility

Mastodon is the largest ActivityPub implementation, making compatibility essential for any fediverse application. This guide covers Mastodon-specific requirements, extensions, and common interoperability issues.

Mastodon's ActivityPub Extensions

Mastodon extends the standard ActivityPub vocabulary with custom properties under the toot namespace:

{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"toot": "http://joinmastodon.org/ns#",
"Emoji": "toot:Emoji",
"featured": "toot:featured",
"featuredTags": "toot:featuredTags",
"discoverable": "toot:discoverable",
"suspended": "toot:suspended",
"focalPoint": {
"@id": "toot:focalPoint",
"@container": "@list"
},
"blurhash": "toot:blurhash"
}
]
}

Actor Requirements

Minimum Actor Structure

{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"toot": "http://joinmastodon.org/ns#",
"discoverable": "toot:discoverable"
}
],
"type": "Person",
"id": "https://example.com/users/alice",
"inbox": "https://example.com/users/alice/inbox",
"outbox": "https://example.com/users/alice/outbox",
"following": "https://example.com/users/alice/following",
"followers": "https://example.com/users/alice/followers",
"preferredUsername": "alice",
"name": "Alice",
"summary": "<p>Hello, I'm Alice!</p>",
"url": "https://example.com/@alice",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/avatars/alice.png"
},
"image": {
"type": "Image",
"mediaType": "image/jpeg",
"url": "https://example.com/headers/alice.jpg"
},
"publicKey": {
"id": "https://example.com/users/alice#main-key",
"owner": "https://example.com/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n..."
},
"discoverable": true,
"endpoints": {
"sharedInbox": "https://example.com/inbox"
}
}

Required Properties

PropertyDescription
inboxURL accepting POST requests for activities
outboxURL returning actor's activities
publicKeyRSA public key for signature verification
preferredUsernameThe @handle part (alphanumeric, underscores)

Mastodon-Specific Properties

PropertyDescription
discoverableWhether to include in directory/search
featuredURL to pinned posts collection
featuredTagsURL to featured hashtags collection
suspendedWhether account is suspended
manuallyApprovesFollowersLocked account requiring follow approval
endpoints.sharedInboxShared inbox for efficient delivery

Visibility Levels

Mastodon implements four visibility levels through addressing:

Public

{
"type": "Create",
"object": { "type": "Note", "content": "..." },
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/users/alice/followers"]
}

Unlisted (Hidden from timelines)

{
"to": ["https://example.com/users/alice/followers"],
"cc": ["https://www.w3.org/ns/activitystreams#Public"]
}

Followers-Only

{
"to": ["https://example.com/users/alice/followers"],
"cc": []
}

Direct Message

{
"to": ["https://example.com/users/bob"],
"cc": []
}

Note Format

Complete Note Example

{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"id": "https://example.com/notes/123",
"attributedTo": "https://example.com/users/alice",
"content": "<p>Hello <span class=\"h-card\"><a href=\"https://example.com/users/bob\" class=\"u-url mention\">@<span>bob</span></a></span>!</p>",
"published": "2024-01-15T12:00:00Z",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": [
"https://example.com/users/alice/followers",
"https://example.com/users/bob"
],
"inReplyTo": null,
"sensitive": false,
"summary": null,
"tag": [
{
"type": "Mention",
"href": "https://example.com/users/bob",
"name": "@bob@example.com"
}
],
"attachment": [],
"url": "https://example.com/@alice/123"
}

Content Formatting

Mastodon expects HTML content with specific formatting:

function formatContent(text, mentions, hashtags) {
let html = escapeHtml(text);

// Format mentions with h-card microformat
for (const mention of mentions) {
const pattern = new RegExp(`@${mention.username}(@${mention.domain})?`, 'g');
html = html.replace(pattern,
`<span class="h-card">` +
`<a href="${mention.url}" class="u-url mention">` +
`@<span>${mention.username}</span>` +
`</a></span>`
);
}

// Format hashtags
for (const tag of hashtags) {
const pattern = new RegExp(`#${tag}`, 'gi');
html = html.replace(pattern,
`<a href="https://example.com/tags/${tag.toLowerCase()}" ` +
`class="mention hashtag" rel="tag">#<span>${tag}</span></a>`
);
}

// Wrap in paragraph
return `<p>${html}</p>`;
}

Content Warnings

{
"type": "Note",
"sensitive": true,
"summary": "Content warning text here",
"content": "<p>The actual post content...</p>"
}

Media Attachments

Image with BlurHash

{
"type": "Document",
"mediaType": "image/jpeg",
"url": "https://example.com/media/photo.jpg",
"name": "Alt text description",
"width": 1920,
"height": 1080,
"blurhash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
"focalPoint": [0.0, 0.0]
}

Video Attachment

{
"type": "Document",
"mediaType": "video/mp4",
"url": "https://example.com/media/video.mp4",
"name": "Video description",
"width": 1280,
"height": 720,
"blurhash": "LGF5]+Yk^6#M@-5c,1J5@[or[Q6."
}

BlurHash Generation

const { encode } = require('blurhash');
const sharp = require('sharp');

async function generateBlurhash(imagePath) {
const { data, info } = await sharp(imagePath)
.raw()
.ensureAlpha()
.resize(32, 32, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });

return encode(
new Uint8ClampedArray(data),
info.width,
info.height,
4, // x components
4 // y components
);
}

Polls

Poll Format

{
"type": "Question",
"id": "https://example.com/notes/poll-123",
"attributedTo": "https://example.com/users/alice",
"content": "<p>What's your favorite color?</p>",
"endTime": "2024-01-16T12:00:00Z",
"oneOf": [
{
"type": "Note",
"name": "Red",
"replies": {
"type": "Collection",
"totalItems": 10
}
},
{
"type": "Note",
"name": "Blue",
"replies": {
"type": "Collection",
"totalItems": 15
}
}
],
"votersCount": 25
}

Voting

{
"type": "Create",
"actor": "https://example.com/users/bob",
"object": {
"type": "Note",
"name": "Blue",
"inReplyTo": "https://example.com/notes/poll-123",
"attributedTo": "https://example.com/users/bob"
}
}

Custom Emoji

{
"type": "Emoji",
"id": "https://example.com/emoji/blobcat",
"name": ":blobcat:",
"updated": "2024-01-01T00:00:00Z",
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://example.com/emoji/blobcat.png"
}
}

HTTP Signature Requirements

Mastodon requires HTTP Signatures on all requests:

Signing Outgoing Requests

const crypto = require('crypto');

function signRequest(request, actor, privateKey) {
const date = new Date().toUTCString();
const url = new URL(request.url);
const digest = crypto
.createHash('sha256')
.update(request.body)
.digest('base64');

const signedHeaders = '(request-target) host date digest';
const signatureString = [
`(request-target): ${request.method.toLowerCase()} ${url.pathname}`,
`host: ${url.host}`,
`date: ${date}`,
`digest: SHA-256=${digest}`
].join('\n');

const signature = crypto
.sign('sha256', Buffer.from(signatureString), privateKey)
.toString('base64');

return {
...request,
headers: {
...request.headers,
'Date': date,
'Digest': `SHA-256=${digest}`,
'Signature': `keyId="${actor.publicKey.id}",` +
`algorithm="rsa-sha256",` +
`headers="${signedHeaders}",` +
`signature="${signature}"`
}
};
}

Verifying Incoming Requests

async function verifySignature(request) {
const signatureHeader = request.headers.signature;
const params = parseSignatureHeader(signatureHeader);

// Fetch the public key
const actor = await fetchActor(params.keyId.split('#')[0]);
const publicKey = actor.publicKey.publicKeyPem;

// Reconstruct the signature string
const headers = params.headers.split(' ');
const signatureString = headers.map(header => {
if (header === '(request-target)') {
return `(request-target): ${request.method.toLowerCase()} ${request.path}`;
}
return `${header}: ${request.headers[header.toLowerCase()]}`;
}).join('\n');

// Verify
return crypto.verify(
'sha256',
Buffer.from(signatureString),
publicKey,
Buffer.from(params.signature, 'base64')
);
}

Common Compatibility Issues

Missing WebFinger

Mastodon requires WebFinger for user discovery:

app.get('/.well-known/webfinger', (req, res) => {
const resource = req.query.resource;
// Handle acct: URIs
if (!resource?.startsWith('acct:')) {
return res.status(400).json({ error: 'Invalid resource' });
}

const [username, domain] = resource.slice(5).split('@');

// Return JRD
res.json({
subject: resource,
links: [{
rel: 'self',
type: 'application/activity+json',
href: `https://${domain}/users/${username}`
}]
});
});

Wrong Content-Type

Mastodon expects specific media types:

// For actor/object responses
res.setHeader('Content-Type', 'application/activity+json');

// For WebFinger
res.setHeader('Content-Type', 'application/jrd+json');

Missing Accept Header Handling

app.get('/users/:username', (req, res) => {
const accept = req.headers.accept || '';

if (accept.includes('application/activity+json') ||
accept.includes('application/ld+json')) {
// Return ActivityPub actor
return res.json(getActor(req.params.username));
}

// Return HTML page
res.render('profile', { username: req.params.username });
});

Signature Verification Failures

Common causes:

  • Clock skew (ensure server time is accurate)
  • Wrong key format
  • Missing headers in signature
  • Incorrect digest calculation
// Ensure body digest matches
const expectedDigest = crypto
.createHash('sha256')
.update(rawBody)
.digest('base64');

const receivedDigest = req.headers.digest?.replace('SHA-256=', '');

if (expectedDigest !== receivedDigest) {
return res.status(401).json({ error: 'Digest mismatch' });
}

Mastodon API Compatibility

Some applications implement Mastodon's REST API alongside ActivityPub:

Status Endpoints

// GET /api/v1/statuses/:id
app.get('/api/v1/statuses/:id', async (req, res) => {
const note = await db.notes.findOne({ id: req.params.id });

res.json({
id: note._id,
created_at: note.published,
content: note.content,
account: await formatAccount(note.attributedTo),
// ... other Mastodon fields
});
});

Account Lookup

// GET /api/v1/accounts/lookup
app.get('/api/v1/accounts/lookup', async (req, res) => {
const acct = req.query.acct;
const actor = await findActorByHandle(acct);

res.json({
id: actor._id,
username: actor.preferredUsername,
acct: `${actor.preferredUsername}@${getDomain(actor.id)}`,
display_name: actor.name,
// ... other fields
});
});

Testing Mastodon Integration

Manual Testing

# Test actor fetch
curl -H "Accept: application/activity+json" \
https://your-server.example/users/alice

# Test WebFinger
curl "https://your-server.example/.well-known/webfinger?resource=acct:alice@your-server.example"

# Search from Mastodon
# Use the Mastodon search box with @alice@your-server.example

Automated Tests

describe('Mastodon Compatibility', () => {
it('should be discoverable via WebFinger', async () => {
const response = await fetch(
'https://your-server.example/.well-known/webfinger?resource=acct:alice@your-server.example'
);

expect(response.status).toBe(200);
const jrd = await response.json();
expect(jrd.links.find(l => l.rel === 'self')).toBeDefined();
});

it('should return valid actor', async () => {
const response = await fetch(
'https://your-server.example/users/alice',
{ headers: { Accept: 'application/activity+json' } }
);

const actor = await response.json();
expect(actor.inbox).toBeDefined();
expect(actor.publicKey).toBeDefined();
});
});

Next Steps