Skip to main content

Handling Incoming Activities

When other servers send activities to your inbox, you need to verify them and process them appropriately. This guide covers the complete inbox handling flow.

Inbox Processing Flow

Activity arrives at inboxVerify HTTP SignatureValidate ActivityCheck Blocks/SpamReturn 202 Accepted→ 401 if invalid→ 400 if malformed→ 403 if blocked

Setting Up the Inbox

app.post('/users/:username/inbox',
express.json({ type: ['application/activity+json', 'application/ld+json'] }),
verifySignature,
async (req, res) => {
try {
await processActivity(req.body, req.params.username);
res.status(202).send('Accepted');
} catch (error) {
console.error('Inbox error:', error);
res.status(500).json({ error: 'Processing failed' });
}
}
);

Activity Validation

function validateActivity(activity) {
// Required fields
if (!activity.type) throw new Error('Missing type');
if (!activity.actor) throw new Error('Missing actor');
if (!activity.id) throw new Error('Missing id');

// Actor must be a URL
if (typeof activity.actor !== 'string' ||
!activity.actor.startsWith('https://')) {
throw new Error('Invalid actor');
}

// Check size limits
const size = JSON.stringify(activity).length;
if (size > 1024 * 1024) { // 1MB
throw new Error('Activity too large');
}

return true;
}

Processing by Type

async function processActivity(activity, targetUsername) {
validateActivity(activity);

switch (activity.type) {
case 'Create':
return handleCreate(activity, targetUsername);
case 'Update':
return handleUpdate(activity, targetUsername);
case 'Delete':
return handleDelete(activity, targetUsername);
case 'Follow':
return handleFollow(activity, targetUsername);
case 'Accept':
return handleAccept(activity, targetUsername);
case 'Reject':
return handleReject(activity, targetUsername);
case 'Like':
return handleLike(activity, targetUsername);
case 'Announce':
return handleAnnounce(activity, targetUsername);
case 'Undo':
return handleUndo(activity, targetUsername);
case 'Block':
return handleBlock(activity, targetUsername);
case 'Flag':
return handleFlag(activity, targetUsername);
default:
console.log('Unknown activity type:', activity.type);
}
}

Handling Create

async function handleCreate(activity, targetUsername) {
const object = activity.object;

// Verify attribution
const attributedTo = typeof object.attributedTo === 'string'
? object.attributedTo
: object.attributedTo?.id;

if (attributedTo !== activity.actor) {
throw new Error('Attribution mismatch');
}

// Check if we should receive this
const isAddressedToUs = isRecipient(activity, targetUsername);
if (!isAddressedToUs) {
return; // Not for us
}

// Store the object
await db.objects.upsert({
id: object.id,
type: object.type,
actor: activity.actor,
data: object,
receivedAt: new Date()
});

// Create notification for mentions
if (object.tag) {
const ourActorUrl = `https://${DOMAIN}/users/${targetUsername}`;
const mention = object.tag.find(t =>
t.type === 'Mention' && t.href === ourActorUrl
);

if (mention) {
await createNotification(targetUsername, 'mention', {
actor: activity.actor,
object: object.id
});
}
}

// If it's a reply to our content, notify
if (object.inReplyTo) {
const parent = await db.objects.findOne({ id: object.inReplyTo });
if (parent && parent.actor === `https://${DOMAIN}/users/${targetUsername}`) {
await createNotification(targetUsername, 'reply', {
actor: activity.actor,
object: object.id,
parent: object.inReplyTo
});
}
}
}

Handling Follow

async function handleFollow(activity, targetUsername) {
const targetActor = `https://${DOMAIN}/users/${targetUsername}`;

// Verify the follow is for this user
if (activity.object !== targetActor) {
return;
}

const followerActor = activity.actor;

// Check if already following
const existing = await db.followers.findOne({
actor: targetActor,
follower: followerActor
});

if (existing) {
// Already following, just send Accept again
await sendAccept(targetUsername, activity);
return;
}

// Check if manually approving
const user = await db.users.findOne({ username: targetUsername });

if (user.manuallyApprovesFollowers) {
// Queue for approval
await db.followRequests.insert({
actor: targetActor,
follower: followerActor,
activity: activity,
createdAt: new Date()
});

await createNotification(targetUsername, 'follow_request', {
actor: followerActor
});
} else {
// Auto-accept
await db.followers.insert({
actor: targetActor,
follower: followerActor,
createdAt: new Date()
});

await sendAccept(targetUsername, activity);

await createNotification(targetUsername, 'follow', {
actor: followerActor
});
}
}

async function sendAccept(username, followActivity) {
const accept = {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Accept',
id: `https://${DOMAIN}/activities/${uuid()}`,
actor: `https://${DOMAIN}/users/${username}`,
object: followActivity
};

const followerInfo = await fetchActor(followActivity.actor);
await sendActivity(followerInfo.inbox, accept, username);
}

Handling Accept

async function handleAccept(activity, targetUsername) {
const accepted = activity.object;

// Check if it's accepting our Follow
if (accepted.type === 'Follow' &&
accepted.actor === `https://${DOMAIN}/users/${targetUsername}`) {

// Add to our following list
await db.following.upsert({
actor: `https://${DOMAIN}/users/${targetUsername}`,
following: activity.actor,
createdAt: new Date()
});

// Update pending follows
await db.pendingFollows.remove({
actor: `https://${DOMAIN}/users/${targetUsername}`,
target: activity.actor
});
}
}

Handling Like

async function handleLike(activity, targetUsername) {
const objectId = typeof activity.object === 'string'
? activity.object
: activity.object.id;

// Check if it's our content
const object = await db.objects.findOne({ id: objectId });
if (!object) return;

if (object.actor !== `https://${DOMAIN}/users/${targetUsername}`) {
return; // Not our content
}

// Store the like
await db.likes.upsert({
actor: activity.actor,
object: objectId,
activityId: activity.id,
createdAt: new Date()
});

// Create notification
await createNotification(targetUsername, 'like', {
actor: activity.actor,
object: objectId
});
}

Handling Announce (Boost)

async function handleAnnounce(activity, targetUsername) {
const objectId = typeof activity.object === 'string'
? activity.object
: activity.object.id;

// Check if it's our content
const object = await db.objects.findOne({ id: objectId });
if (!object) return;

if (object.actor !== `https://${DOMAIN}/users/${targetUsername}`) {
return;
}

// Store the boost
await db.announces.upsert({
actor: activity.actor,
object: objectId,
activityId: activity.id,
createdAt: new Date()
});

// Create notification
await createNotification(targetUsername, 'boost', {
actor: activity.actor,
object: objectId
});
}

Handling Undo

async function handleUndo(activity, targetUsername) {
const undone = activity.object;

// Verify the actor owns the original activity
if (typeof undone === 'object' && undone.actor !== activity.actor) {
throw new Error('Cannot undo activity you do not own');
}

const undoneType = typeof undone === 'string'
? (await db.activities.findOne({ id: undone }))?.type
: undone.type;

switch (undoneType) {
case 'Follow':
await db.followers.remove({
follower: activity.actor,
actor: `https://${DOMAIN}/users/${targetUsername}`
});
break;

case 'Like':
await db.likes.remove({
actor: activity.actor,
object: undone.object
});
break;

case 'Announce':
await db.announces.remove({
actor: activity.actor,
object: undone.object
});
break;
}
}

Handling Delete

async function handleDelete(activity, targetUsername) {
const objectId = typeof activity.object === 'string'
? activity.object
: activity.object.id;

// Verify actor owns the object
const object = await db.objects.findOne({ id: objectId });
if (object && object.actor !== activity.actor) {
throw new Error('Cannot delete object you do not own');
}

// Mark as deleted (Tombstone)
await db.objects.update(
{ id: objectId },
{
$set: {
type: 'Tombstone',
formerType: object?.type,
deleted: new Date()
}
}
);

// Clean up related data
await db.likes.remove({ object: objectId });
await db.announces.remove({ object: objectId });
}

Helper Functions

function isRecipient(activity, username) {
const ourActor = `https://${DOMAIN}/users/${username}`;
const ourFollowers = `${ourActor}/followers`;
const publicAddress = 'https://www.w3.org/ns/activitystreams#Public';

const recipients = [
...(activity.to || []),
...(activity.cc || []),
...(activity.audience || [])
];

return recipients.some(r =>
r === ourActor ||
r === ourFollowers ||
r === publicAddress
);
}

async function createNotification(username, type, data) {
await db.notifications.insert({
username,
type,
data,
read: false,
createdAt: new Date()
});
}

Idempotency

Make sure processing is idempotent:

async function handleActivityIdempotent(activity) {
// Check if already processed
const existing = await db.processedActivities.findOne({
id: activity.id
});

if (existing) {
return; // Already processed
}

// Process activity
await processActivity(activity);

// Mark as processed
await db.processedActivities.insert({
id: activity.id,
processedAt: new Date()
});
}

Next Steps