Skip to main content

Actor Inspector

Inspecting ActivityPub actors helps debug federation issues and understand how different platforms represent users, groups, and services.

Fetching Actors

Command Line

# Fetch an actor with proper Accept header
curl -H "Accept: application/activity+json" \
"https://mastodon.social/users/Gargron" | jq .

# Alternative content type
curl -H "Accept: application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"" \
"https://mastodon.social/users/Gargron" | jq .

JavaScript

async function fetchActor(url) {
const response = await fetch(url, {
headers: {
'Accept': 'application/activity+json, application/ld+json'
}
});

if (!response.ok) {
throw new Error(`Failed to fetch actor: ${response.status}`);
}

return response.json();
}

// Usage
const actor = await fetchActor('https://mastodon.social/users/Gargron');
console.log(actor);

Actor Structure

Typical Mastodon Actor

{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"toot": "http://joinmastodon.org/ns#",
"discoverable": "toot:discoverable",
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers"
}
],
"id": "https://mastodon.social/users/Gargron",
"type": "Person",
"preferredUsername": "Gargron",
"name": "Eugen Rochko",
"summary": "<p>Developer of Mastodon</p>",
"inbox": "https://mastodon.social/users/Gargron/inbox",
"outbox": "https://mastodon.social/users/Gargron/outbox",
"followers": "https://mastodon.social/users/Gargron/followers",
"following": "https://mastodon.social/users/Gargron/following",
"publicKey": {
"id": "https://mastodon.social/users/Gargron#main-key",
"owner": "https://mastodon.social/users/Gargron",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
},
"endpoints": {
"sharedInbox": "https://mastodon.social/inbox"
}
}

Validation Checklist

┌────────────────────────────────────────────────────────────┐
│ ACTOR VALIDATION │
├────────────────────────────────────────────────────────────┤
│ │
│ Required Properties │
│ ✓ id - Unique URI │
│ ✓ type - Person, Group, Service, etc. │
│ ✓ inbox - URL to receive activities │
│ ✓ outbox - URL of published activities │
│ │
│ Recommended Properties │
│ ○ preferredUsername - The @handle │
│ ○ name - Display name │
│ ○ publicKey - For HTTP Signatures │
│ ○ followers - Followers collection │
│ ○ following - Following collection │
│ ○ endpoints.sharedInbox - For efficient delivery │
│ │
└────────────────────────────────────────────────────────────┘

Actor Inspector Tool

Full Validation Script

class ActorInspector {
constructor(actorUrl) {
this.url = actorUrl;
this.actor = null;
this.errors = [];
this.warnings = [];
}

async fetch() {
const response = await fetch(this.url, {
headers: { 'Accept': 'application/activity+json' }
});

if (!response.ok) {
this.errors.push(`Fetch failed: ${response.status}`);
return false;
}

this.actor = await response.json();
return true;
}

validate() {
const a = this.actor;

// Required fields
if (!a.id) this.errors.push('Missing required: id');
if (!a.type) this.errors.push('Missing required: type');
if (!a.inbox) this.errors.push('Missing required: inbox');
if (!a.outbox) this.errors.push('Missing required: outbox');

// Type validation
const validTypes = ['Person', 'Group', 'Organization', 'Application', 'Service'];
if (a.type && !validTypes.includes(a.type)) {
this.warnings.push(`Unusual actor type: ${a.type}`);
}

// URL validation
if (a.id && !a.id.startsWith('https://')) {
this.errors.push('Actor id must be HTTPS');
}

if (a.inbox && !a.inbox.startsWith('https://')) {
this.errors.push('Inbox must be HTTPS');
}

// Recommended fields
if (!a.preferredUsername) {
this.warnings.push('Missing recommended: preferredUsername');
}

if (!a.publicKey) {
this.warnings.push('Missing recommended: publicKey (needed for federation)');
}

if (!a.endpoints?.sharedInbox) {
this.warnings.push('Missing recommended: endpoints.sharedInbox');
}

// Public key validation
if (a.publicKey) {
if (!a.publicKey.id) this.errors.push('publicKey missing id');
if (!a.publicKey.owner) this.errors.push('publicKey missing owner');
if (!a.publicKey.publicKeyPem) this.errors.push('publicKey missing publicKeyPem');

if (a.publicKey.owner && a.publicKey.owner !== a.id) {
this.errors.push('publicKey.owner must match actor id');
}
}

return this.errors.length === 0;
}

report() {
console.log('=== Actor Inspector Report ===\n');
console.log(`URL: ${this.url}`);
console.log(`Type: ${this.actor?.type}`);
console.log(`Username: ${this.actor?.preferredUsername}`);
console.log(`Name: ${this.actor?.name || '(not set)'}`);

console.log('\n--- Endpoints ---');
console.log(`Inbox: ${this.actor?.inbox}`);
console.log(`Outbox: ${this.actor?.outbox}`);
console.log(`Shared Inbox: ${this.actor?.endpoints?.sharedInbox || '(not set)'}`);

if (this.errors.length > 0) {
console.log('\n--- Errors ---');
this.errors.forEach(e => console.log(`${e}`));
}

if (this.warnings.length > 0) {
console.log('\n--- Warnings ---');
this.warnings.forEach(w => console.log(`⚠️ ${w}`));
}

if (this.errors.length === 0 && this.warnings.length === 0) {
console.log('\n✅ All checks passed!');
}
}
}

// Usage
const inspector = new ActorInspector('https://mastodon.social/users/Gargron');
await inspector.fetch();
inspector.validate();
inspector.report();

Comparing Actors

Platform Differences

PropertyMastodonLemmyPixelfedPeerTube
typePersonPerson/GroupPersonPerson/Group
preferredUsername
nameDisplay nameDisplay nameDisplay nameChannel name
summaryHTML bioHTML bioHTML bioHTML description
iconAvatarAvatarAvatarAvatar
imageHeaderBanner-Banner
discoverable--

Fetch Multiple Actors

async function compareActors(urls) {
const actors = await Promise.all(
urls.map(async url => {
try {
const response = await fetch(url, {
headers: { 'Accept': 'application/activity+json' }
});
return { url, actor: await response.json() };
} catch (error) {
return { url, error: error.message };
}
})
);

// Compare properties
const properties = ['type', 'preferredUsername', 'name', 'inbox', 'outbox'];

console.log('Property comparison:');
properties.forEach(prop => {
console.log(`\n${prop}:`);
actors.forEach(({ url, actor, error }) => {
if (error) {
console.log(` ${url}: ERROR - ${error}`);
} else {
console.log(` ${url}: ${actor[prop] || '(not set)'}`);
}
});
});
}

Endpoint Testing

Test Inbox

# Note: Requires valid HTTP signature for real servers
curl -X POST \
-H "Content-Type: application/activity+json" \
-d '{"type": "Create", "actor": "...", "object": "..."}' \
"https://example.com/users/alice/inbox"

Test Outbox (Read)

# Fetch recent activities
curl -H "Accept: application/activity+json" \
"https://mastodon.social/users/Gargron/outbox" | jq .

Test Collections

# Followers (may be empty or restricted)
curl -H "Accept: application/activity+json" \
"https://mastodon.social/users/Gargron/followers" | jq .

# Following
curl -H "Accept: application/activity+json" \
"https://mastodon.social/users/Gargron/following" | jq .

Common Issues

406 Not Acceptable

Server doesn't recognize Accept header:

# Try alternative
curl -H "Accept: application/ld+json" "https://example.com/users/alice"

401/403 Unauthorized

Some endpoints require authentication:

// Outbox might be public, but inbox always requires signature
// Followers/following may be private

Different URL vs ID

Actor URL and ID should match:

{
"id": "https://example.com/users/alice",
"url": "https://example.com/@alice"
}

The id is for ActivityPub, url is the human-readable profile page.