← Back to DevBytes

Implementing ActivityPub Protocol: From Theory to Practice

What is ActivityPub?

ActivityPub is a decentralized social networking protocol developed by the W3C Social Web Working Group. It defines two distinct layers: the Client-to-Server API (C2S) for direct user interaction with their chosen server, and the Server-to-Server API (S2S) for federation between servers across the open web. At its core, ActivityPub models social interactions as Activities — discrete actions like "Create", "Like", "Follow", or "Announce" — performed by Actors (users, groups, or bots) on Objects (notes, articles, images, or other actors).

The protocol uses Activity Streams 2.0 vocabulary serialized as JSON-LD, which provides a rich, extensible way to describe social graph interactions. Every actor has an inbox for receiving activities and an outbox for publishing them. When you post a status update, your server creates a Create activity in your outbox and delivers it to the inboxes of your followers across any compliant server anywhere on the internet.

Why ActivityPub Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

The significance of ActivityPub extends far beyond technical curiosity. It represents the foundation of the fediverse — a network of interconnected but independently operated servers that collectively form a resilient, censorship-resistant social web. Platforms like Mastodon, Pixelfed, PeerTube, and WriteFreely all speak ActivityPub, enabling seamless cross-service interaction without a central authority owning user relationships or data.

Core Concepts: Actors, Activities, and Objects

Before writing code, you must internalize the data model. Every entity in ActivityPub is addressable by an IRI (Internationalized Resource Identifier) that resolves to a JSON-LD document. There are three fundamental types:

Actors

An Actor represents a user, bot, or service that can perform activities. Actor objects contain properties like inbox, outbox, followers, following, preferredUsername, and publicKey. Here is a minimal actor document:

{
  "@context": [
    "https://www.w3.org/ns/activitystreams",
    "https://w3id.org/security/v1"
  ],
  "id": "https://example.social/users/alice",
  "type": "Person",
  "preferredUsername": "alice",
  "name": "Alice Example",
  "summary": "Hello fediverse!",
  "inbox": "https://example.social/users/alice/inbox",
  "outbox": "https://example.social/users/alice/outbox",
  "followers": "https://example.social/users/alice/followers",
  "following": "https://example.social/users/alice/following",
  "publicKey": {
    "id": "https://example.social/users/alice#main-key",
    "owner": "https://example.social/users/alice",
    "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----"
  }
}

Objects

Objects are the targets of activities — notes, articles, images, videos, or even other actors. The most common object in microblogging is Note, typically containing content, attributedTo, and sometimes inReplyTo for threaded conversations:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.social/users/alice/statuses/abc123",
  "type": "Note",
  "attributedTo": "https://example.social/users/alice",
  "content": "Hello world, this is my first post on the fediverse!",
  "published": "2025-01-15T14:30:00Z",
  "to": ["https://www.w3.org/ns/activitystreams#Public"],
  "cc": ["https://example.social/users/alice/followers"]
}

Activities

Activities wrap objects with a verb describing what happened. A Create activity announces a new object; a Like activity expresses appreciation; a Follow activity requests a subscription. The activity itself is what gets delivered to inboxes:

{
  "@context": "https://www.w3.org/ns/activitystreams",
  "id": "https://example.social/users/alice/activities/create/xyz789",
  "type": "Create",
  "actor": "https://example.social/users/alice",
  "object": {
    "type": "Note",
    "id": "https://example.social/users/alice/statuses/abc123",
    "attributedTo": "https://example.social/users/alice",
    "content": "Hello world, this is my first post!",
    "published": "2025-01-15T14:30:00Z"
  },
  "to": ["https://www.w3.org/ns/activitystreams#Public"],
  "cc": ["https://example.social/users/alice/followers"]
}

Setting Up Your First ActivityPub Server

Let's build a minimal ActivityPub server in Node.js with Express. This implementation will handle webfinger discovery, serve actor documents, and accept deliveries to inboxes. We'll start with the scaffolding:

// server.js
const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');
const jsonld = require('jsonld');
const app = express();

// Store actors and their inboxes in memory for simplicity
const actors = new Map();
const inboxes = new Map();

// Parse incoming JSON-LD with raw body available for signature verification
app.use(bodyParser.json({
  type: ['application/json', 'application/ld+json',
         'application/activity+json'],
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

const PORT = process.env.PORT || 3000;
const DOMAIN = process.env.DOMAIN || `localhost:${PORT}`;
const BASE_URL = `https://${DOMAIN}`;

app.listen(PORT, () => {
  console.log(`ActivityPub server running on ${BASE_URL}`);
  // Seed an example actor for testing
  initializeActor('alice');
});

Implementing Webfinger Discovery

When a remote server wants to find your user alice@example.social, it performs a webfinger lookup. Your server must expose a /.well-known/webfinger endpoint that accepts a resource query parameter and returns a JSON document linking to the actor's ActivityPub profile. This is the first step in federation:

// Webfinger endpoint
app.get('/.well-known/webfinger', (req, res) => {
  const resource = req.query.resource;

  if (!resource) {
    return res.status(400).json({ error: 'Missing resource parameter' });
  }

  // Parse acct:alice@example.social format
  const match = resource.match(/^acct:(.+)@(.+)$/);
  if (!match) {
    return res.status(400).json({ error: 'Invalid resource format' });
  }

  const username = match[1];
  const domain = match[2];

  // Validate domain matches our server
  if (domain !== DOMAIN) {
    return res.status(404).json({ error: 'Domain not served here' });
  }

  const actor = actors.get(username);
  if (!actor) {
    return res.status(404).json({ error: 'User not found' });
  }

  res.json({
    subject: `acct:${username}@${DOMAIN}`,
    aliases: [actor.id],
    links: [
      {
        rel: 'self',
        type: 'application/activity+json',
        href: actor.id
      }
    ]
  });
});

Serving Actor Documents

When a federated server dereferences the actor IRI, it must receive a complete ActivityPub actor document with application/activity+json content type. Include security context and a public key for HTTP signature verification:

function initializeActor(username) {
  const actorId = `${BASE_URL}/users/${username}`;
  const keyPair = crypto.generateKeyPairSync('rsa', {
    modulusLength: 4096,
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
  });

  const actor = {
    id: actorId,
    type: 'Person',
    preferredUsername: username,
    name: username.charAt(0).toUpperCase() + username.slice(1),
    summary: 'A friendly fediverse user',
    inbox: `${actorId}/inbox`,
    outbox: `${actorId}/outbox`,
    followers: `${actorId}/followers`,
    following: `${actorId}/following`,
    publicKey: {
      id: `${actorId}#main-key`,
      owner: actorId,
      publicKeyPem: keyPair.publicKey
    },
    privateKeyPem: keyPair.privateKey // stored server-side, never exposed
  };

  actors.set(username, actor);
  inboxes.set(username, []);
  return actor;
}

// Serve actor document with proper content negotiation
app.get('/users/:username', async (req, res) => {
  const actor = actors.get(req.params.username);
  if (!actor) return res.status(404).json({ error: 'Actor not found' });

  const acceptHeader = req.headers.accept || '';

  if (acceptHeader.includes('application/activity+json') ||
      acceptHeader.includes('application/ld+json') ||
      acceptHeader.includes('*/*')) {

    // Never expose private key in actor document
    const { privateKeyPem, ...publicActor } = actor;

    res.set('Content-Type', 'application/activity+json');
    res.json(publicActor);
  } else {
    // For browser requests, redirect to a human-friendly profile page
    res.redirect(`/profile/${req.params.username}`);
  }
});

Handling Inbox Delivery

The inbox is the most critical endpoint. It accepts POST requests with activities from both local clients (C2S) and remote servers (S2S). Every incoming activity must be verified — either by session authentication for local users or by HTTP signature verification for federated requests. Here's a robust inbox handler:

app.post('/users/:username/inbox', async (req, res) => {
  const username = req.params.username;
  const actor = actors.get(username);

  if (!actor) {
    return res.status(404).json({ error: 'Actor not found' });
  }

  let activity;
  try {
    activity = req.body;
  } catch (err) {
    return res.status(400).json({ error: 'Invalid JSON body' });
  }

  // Determine if this is a local or federated request
  const isLocalRequest = req.headers['x-local-auth'] === 'valid';

  if (!isLocalRequest) {
    // Verify HTTP signature for federated requests
    const verified = await verifyHttpSignature(req, actor);
    if (!verified) {
      return res.status(401).json({ error: 'Signature verification failed' });
    }
  }

  // Store activity in inbox collection
  const inbox = inboxes.get(username);
  inbox.push({
    activity: activity,
    receivedAt: new Date().toISOString(),
    deliveredBy: isLocalRequest ? 'c2s' : 's2s'
  });

  // Process side effects based on activity type
  await processInboxActivity(username, activity);

  // Always return 202 Accepted per spec
  res.status(202).json({ status: 'accepted' });
});

// Side effect processing for various activity types
async function processInboxActivity(username, activity) {
  const actor = actors.get(username);
  const activityType = activity.type;

  switch (activityType) {
    case 'Follow':
      // Auto-accept follows for simplicity; real implementations queue this
      console.log(`${username} received a Follow from ${activity.actor}`);
      const acceptActivity = {
        '@context': 'https://www.w3.org/ns/activitystreams',
        type: 'Accept',
        actor: actor.id,
        object: activity,
        to: [activity.actor]
      };
      // In production, deliver this Accept back to the follower's inbox
      break;

    case 'Create':
      console.log(`${username} received a Create activity:`,
                  activity.object?.content);
      break;

    case 'Like':
      console.log(`${username} received a Like on object:`,
                  activity.object);
      break;

    case 'Announce':
      console.log(`${username} received an Announce (boost) of:`,
                  activity.object);
      break;

    case 'Undo':
      console.log(`${username} received an Undo for:`,
                  activity.object?.type);
      break;

    case 'Delete':
      console.log(`${username} received a Delete for:`,
                  activity.object);
      break;

    default:
      console.log(`${username} received unknown activity type:`,
                  activityType);
  }
}

HTTP Signatures for Authentication

Federation security relies on HTTP Signatures (draft-cavage-http-signatures). When a remote server delivers an activity, it signs the request with its actor's private key. Your server verifies by fetching the remote actor's public key and checking the signature. Here's a production-ready verification function:

const axios = require('axios');
const cryptoLib = require('crypto');

async function verifyHttpSignature(req, localActor) {
  try {
    const signatureHeader = req.headers['signature'];
    if (!signatureHeader) {
      console.log('Missing Signature header');
      return false;
    }

    // Parse the Signature header into key-value pairs
    const signatureParts = {};
    signatureHeader.split(',').forEach(part => {
      const eqIndex = part.indexOf('=');
      if (eqIndex === -1) return;
      const key = part.slice(0, eqIndex).trim();
      let value = part.slice(eqIndex + 1).trim();
      // Strip surrounding quotes
      if (value.startsWith('"') && value.endsWith('"')) {
        value = value.slice(1, -1);
      }
      signatureParts[key] = value;
    });

    const keyId = signatureParts.keyId;
    const algorithm = signatureParts.algorithm || 'rsa-sha256';
    const signature = Buffer.from(signatureParts.signature, 'base64');
    const headers = signatureParts.headers || '(request-target) host date';

    if (!keyId) {
      console.log('Missing keyId in Signature header');
      return false;
    }

    // Fetch the signing actor's public key
    // keyId format: https://remote.example/users/bob#main-key
    const keyOwnerUrl = keyId.split('#')[0];
    let remoteActor;
    try {
      const response = await axios.get(keyOwnerUrl, {
        headers: { 'Accept': 'application/activity+json' },
        timeout: 5000
      });
      remoteActor = response.data;
    } catch (err) {
      console.log(`Failed to fetch remote actor: ${err.message}`);
      return false;
    }

    const publicKeyPem = remoteActor.publicKey?.publicKeyPem;
    if (!publicKeyPem) {
      console.log('Remote actor has no publicKeyPem');
      return false;
    }

    // Construct the signing string from the specified headers
    const signingLines = [];
    const headerList = headers.split(' ');

    for (const headerName of headerList) {
      if (headerName === '(request-target)') {
        const targetPath = req.originalUrl || req.url;
        signingLines.push(
          `(request-target): ${req.method.toLowerCase()} ${targetPath}`
        );
      } else if (headerName === 'host') {
        signingLines.push(`host: ${req.headers.host}`);
      } else if (headerName === 'date') {
        signingLines.push(`date: ${req.headers.date || ''}`);
      } else if (headerName === 'digest') {
        signingLines.push(
          `digest: ${req.headers.digest || ''}`
        );
      } else if (headerName === 'content-type') {
        signingLines.push(
          `content-type: ${req.headers['content-type'] || ''}`
        );
      }
    }

    const signingString = signingLines.join('\n');

    // Verify the signature
    const verifier = cryptoLib.createVerify('SHA256');
    verifier.update(signingString);
    verifier.end();

    const isValid = verifier.verify(
      publicKeyPem,
      signature,
      'base64'
    );

    if (!isValid) {
      console.log('Cryptographic signature verification failed');
      return false;
    }

    // Optional: check Date header freshness (within 30 seconds)
    if (req.headers.date) {
      const requestDate = new Date(req.headers.date).getTime();
      const now = Date.now();
      const skew = Math.abs(now - requestDate) / 1000;
      if (skew > 30) {
        console.log(`Date skew too large: ${skew}s`);
        return false;
      }
    }

    return true;
  } catch (err) {
    console.error('Signature verification error:', err.message);
    return false;
  }
}

Implementing the Outbox and Client-to-Server API

The C2S API allows authenticated users to create activities that get stored in their outbox and federated to followers. Here's the outbox endpoint and delivery logic:

// Client-to-Server: Post to outbox
app.post('/users/:username/outbox', async (req, res) => {
  const username = req.params.username;
  const actor = actors.get(username);

  if (!actor) {
    return res.status(404).json({ error: 'Actor not found' });
  }

  // In production, authenticate with OAuth2 or session tokens
  const isAuthenticated = req.headers['authorization'] === 'Bearer valid-token';
  if (!isAuthenticated) {
    return res.status(403).json({ error: 'Authentication required' });
  }

  const activity = req.body;

  // Validate basic activity structure
  if (!activity.type) {
    return res.status(400).json({ error: 'Activity must have a type' });
  }

  // Assign an ID and actor if not present
  if (!activity.id) {
    const activityId = `${actor.id}/activities/${generateId()}`;
    activity.id = activityId;
  }
  if (!activity.actor) {
    activity.actor = actor.id;
  }

  // Ensure @context is present
  if (!activity['@context']) {
    activity['@context'] = 'https://www.w3.org/ns/activitystreams';
  }

  // Handle Create activities: the object gets its own ID
  if (activity.type === 'Create' && activity.object) {
    if (!activity.object.id) {
      activity.object.id =
        `${actor.id}/objects/${generateId()}`;
    }
    if (!activity.object.attributedTo) {
      activity.object.attributedTo = actor.id;
    }
  }

  // Store in outbox collection
  const outboxCollection = outboxes.get(username);
  outboxCollection.push(activity);

  // Deliver to followers and mentioned recipients
  await deliverToRecipients(actor, activity);

  res.status(201).json(activity);
});

// Outbox read endpoint (ordered collection)
app.get('/users/:username/outbox', (req, res) => {
  const actor = actors.get(req.params.username);
  if (!actor) return res.status(404).json({ error: 'Not found' });

  const items = outboxes.get(req.params.username) || [];
  const page = items.slice(-20); // newest 20 items

  res.set('Content-Type', 'application/activity+json');
  res.json({
    '@context': 'https://www.w3.org/ns/activitystreams',
    type: 'OrderedCollection',
    id: `${actor.id}/outbox`,
    totalItems: items.length,
    orderedItems: page
  });
});

Federated Delivery to Remote Inboxes

When a local user creates a post, your server must deliver it to every follower's inbox — including followers on completely different servers. This requires HTTP POST with signature signing:

async function deliverToRecipients(actor, activity) {
  // Determine recipient inboxes from to, cc, and followers
  const recipientUris = new Set();

  // Collect explicit recipients from to and cc fields
  for (const field of ['to', 'cc']) {
    const recipients = activity[field] || [];
    for (const recipient of recipients) {
      if (recipient === 'https://www.w3.org/ns/activitystreams#Public') {
        // Public delivery means all followers
        const followersList = followers.get(actor.id) || [];
        followersList.forEach(f => recipientUris.add(f));
      } else if (recipient.startsWith('http')) {
        recipientUris.add(recipient);
      }
    }
  }

  // Resolve actor IRIs to inbox IRIs
  const inboxUrls = [];
  for (const uri of recipientUris) {
    if (uri.includes('/inbox')) {
      inboxUrls.push(uri);
    } else {
      // Dereference actor to find their inbox
      try {
        const response = await axios.get(uri, {
          headers: { 'Accept': 'application/activity+json' },
          timeout: 5000
        });
        if (response.data.inbox) {
          inboxUrls.push(response.data.inbox);
        }
      } catch (err) {
        console.log(`Failed to resolve inbox for ${uri}: ${err.message}`);
      }
    }
  }

  // Deduplicate
  const uniqueInboxes = [...new Set(inboxUrls)];

  // Deliver to each inbox (with retry logic in production)
  for (const inboxUrl of uniqueInboxes) {
    try {
      await signAndPost(actor, inboxUrl, activity);
      console.log(`Delivered ${activity.type} to ${inboxUrl}`);
    } catch (err) {
      console.error(`Delivery failed to ${inboxUrl}: ${err.message}`);
      // Queue for retry in production
    }
  }
}

async function signAndPost(actor, inboxUrl, activity) {
  const body = JSON.stringify(activity);
  const crypto = require('crypto');

  // Create Digest header per specification
  const digest = crypto
    .createHash('sha256')
    .update(body)
    .digest('base64');
  const digestHeader = `SHA-256=${digest}`;

  const date = new Date().toUTCString();
  const targetUrl = new URL(inboxUrl);
  const targetPath = targetUrl.pathname + targetUrl.search;

  // Build signing string
  const signingLines = [
    `(request-target): post ${targetPath}`,
    `host: ${targetUrl.host}`,
    `date: ${date}`,
    `digest: ${digestHeader}`,
    `content-type: application/activity+json`
  ];
  const signingString = signingLines.join('\n');

  // Sign with actor's private key
  const signer = crypto.createSign('sha256');
  signer.update(signingString);
  signer.end();
  const signature = signer.sign(actor.privateKeyPem, 'base64');

  const signatureHeader = [
    `keyId="${actor.publicKey.id}"`,
    `algorithm="rsa-sha256"`,
    `headers="(request-target) host date digest content-type"`,
    `signature="${signature}"`
  ].join(',');

  const response = await axios.post(inboxUrl, activity, {
    headers: {
      'Content-Type': 'application/activity+json',
      'Date': date,
      'Digest': digestHeader,
      'Signature': signatureHeader,
      'Host': targetUrl.host
    },
    timeout: 10000
  });

  return response;
}

Managing Followers and Following Collections

Social graph management is central to federation. When a Follow activity arrives in an inbox, your server should update the follower collection and send an Accept back. Here's how to handle collections:

// Follower collection endpoint
app.get('/users/:username/followers', (req, res) => {
  const actor = actors.get(req.params.username);
  if (!actor) return res.status(404).json({ error: 'Not found' });

  const followerList = followers.get(actor.id) || [];

  res.set('Content-Type', 'application/activity+json');
  res.json({
    '@context': 'https://www.w3.org/ns/activitystreams',
    type: 'OrderedCollection',
    id: `${actor.id}/followers`,
    totalItems: followerList.length,
    orderedItems: followerList // In production, paginate this
  });
});

// Enhanced inbox processing for Follow activities
async function processFollowActivity(localUsername, activity) {
  const localActor = actors.get(localUsername);
  const followerId = activity.actor;

  // Add to followers collection
  if (!followers.has(localActor.id)) {
    followers.set(localActor.id, []);
  }
  const followerList = followers.get(localActor.id);

  if (!followerList.includes(followerId)) {
    followerList.push(followerId);
  }

  // Send Accept activity back to the follower's inbox
  const acceptActivity = {
    '@context': 'https://www.w3.org/ns/activitystreams',
    type: 'Accept',
    id: `${localActor.id}/activities/${generateId()}`,
    actor: localActor.id,
    object: {
      type: 'Follow',
      actor: followerId,
      object: localActor.id
    },
    to: [followerId]
  };

  // Resolve follower's inbox
  try {
    const response = await axios.get(followerId, {
      headers: { 'Accept': 'application/activity+json' }
    });
    const followerInbox = response.data.inbox;
    await signAndPost(localActor, followerInbox, acceptActivity);
  } catch (err) {
    console.error(`Failed to deliver Accept to ${followerId}:`, err.message);
  }
}

Content Negotiation and JSON-LD

ActivityPub mandates proper content negotiation. Servers must respond with application/activity+json when that type is requested, and may fall back to application/ld+json. The Accept header drives this decision. Additionally, JSON-LD compaction can reduce payload sizes while preserving linked data semantics:

// Utility: compact JSON-LD for efficient delivery
async function compactActivity(activity) {
  const context = {
    '@context': [
      'https://www.w3.org/ns/activitystreams',
      {
        'ostatus': 'http://ostatus.org#',
        'discoverable': 'ostatus:discoverable',
        'sensitive': 'as:sensitive',
        'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers'
      }
    ]
  };

  try {
    const compacted = await jsonld.compact(
      activity,
      context['@context']
    );
    return compacted;
  } catch (err) {
    // If compaction fails, return original with context
    return {
      ...activity,
      '@context': 'https://www.w3.org/ns/activitystreams'
    };
  }
}

// Content negotiation middleware
function contentNegotiation(req, res, next) {
  const accept = req.headers.accept || '*/*';

  if (accept.includes('application/activity+json') ||
      accept.includes('application/ld+json') ||
      accept.includes('*/*')) {
    res.set('Content-Type', 'application/activity+json');
    res.set('Vary', 'Accept');
    return next();
  }

  // HTML fallback for browser visitors
  res.set('Content-Type', 'text/html');
  res.send(`

This is an ActivityPub endpoint. Please use a compatible client.

Best Practices for ActivityPub Implementations

1. Embrace Idempotency and Deduplication

Activities carry unique IDs. Your inbox handler must deduplicate deliveries — the same activity may arrive multiple times due to federation retries or multiple delivery paths. Store delivered activity IDs and skip reprocessing:

const deliveredActivityIds = new Set();

async function deduplicateInbox(username, activity) {
  const activityId = activity.id;
  if (activityId && deliveredActivityIds.has(activityId)) {
    console.log(`Duplicate activity ${activityId} — skipping`);
    return false; // already processed
  }
  if (activityId) {
    deliveredActivityIds.add(activityId);
  }
  return true; // proceed with processing
}

2. Implement Robust Error Handling and Retry Queues

Federation is inherently unreliable — remote servers go down, network partitions occur, and DNS fails. Never assume a delivery will succeed on the first attempt. Implement exponential backoff with a persistent queue:

const deliveryQueue = []; // In production, use Redis or a database

async function queueDelivery(actor, inboxUrl, activity, attempt = 1) {
  deliveryQueue.push({
    actor: actor.id,
    inboxUrl,
    activity,
    attempt,
    nextAttempt: Date.now() + (Math.pow(2, attempt) * 1000),
    maxAttempts: 7
  });
}

async function processDeliveryQueue() {
  const now = Date.now();
  const readyJobs = deliveryQueue.filter(j => j.nextAttempt <= now);

  for (const job of readyJobs) {
    try {
      await signAndPost(
        actors.get(job.actor.split('/').pop()),
        job.inboxUrl,
        job.activity
      );
      // Success: remove from queue
      const index = deliveryQueue.indexOf(job);
      deliveryQueue.splice(index, 1);
    } catch (err) {
      if (job.attempt >= job.maxAttempts) {
        console.error(`Permanent failure delivering to ${job.inboxUrl}`);
        const index = deliveryQueue.indexOf(job);
        deliveryQueue.splice(index, 1);
      } else {
        job.attempt += 1;
        job.nextAttempt = Date.now() +
          (Math.pow(2, job.attempt) * 1000 * 60); // backoff in minutes
      }
    }
  }
}

// Run queue processor every 30 seconds
setInterval(processDeliveryQueue, 30000);

3. Validate and Sanitize Incoming Content

Remote servers are untrusted. Activities may contain malicious HTML, oversized payloads, or malformed JSON-LD. Always sanitize content fields, limit payload size, and validate JSON-LD structure before processing:

const MAX_PAYLOAD_SIZE = 256 * 1024; // 256KB

function sanitizeActivity(activity) {
  // Strip dangerous HTML from Note content
  if (activity.object && activity.object.type === 'Note') {
    const content = activity.object.content || '';
    // Allow only basic formatting tags
    const sanitized = content
      .replace(/]*>[\s\S]*?<\/script>/gi, '')
      .replace(/]*>[\s\S]*?<\/style>/gi, '')
      .replace(/javascript:/gi, '')
      .replace(/on\w+=/gi, '');
    activity.object.content = sanitized;
  }

  // Truncate excessively long content
  if (activity.object?.content?.length > 50000) {
    activity.object.content =
      activity.object.content.slice(0, 50000) + '…';
  }

  return activity;
}

4. Respect Rate Limits and Delivery Windows

A well-behaved implementation throttles outbound deliveries to avoid overwhelming remote servers. Track per-domain delivery rates and pause delivery to unresponsive inboxes:

const domainRateLimiter = new Map();

async function rateLimitedDelivery(actor, inboxUrl, activity) {
  const domain = new URL(inboxUrl).host;

  if (!domainRateLimiter.has(domain)) {
    domainRateLimiter.set(domain, {
      lastDelivery: 0,
      consecutiveFailures: 0
    });
  }

  const rateState = domainRateLimiter.get(domain);
  const now = Date.now();

  // If domain has been failing, back off
  if (rateState.consecutiveFailures >= 3) {
    const backoffTime = Math.pow(2, rateState.consecutiveFailures) * 1000;
    if (now - rateState.lastDelivery < backoffTime) {
      console.log(`Backing off delivery to ${domain}`);
      return queueDelivery(actor, inboxUrl, activity);
    }
  }

  // Minimum 50ms between deliveries to same domain
  if (now - rateState.lastDelivery < 50) {
    await new Promise(r => setTimeout(r, 50 - (now - rateState.lastDelivery)));

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles