ActivityPub federation for forums and blogs

Embed
Preview of the card
<a href="https://forumulate.com/guides/activitypub-federation"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/3657941f-5517-431b-af58-595e65fe7900/social-card.svg?v=3e7ae33eec8b" alt="ActivityPub federation for forums and blogs" width="600" height="315"></a>

A community space or a blog can be an account on the fediverse. People on Mastodon, Lemmy, Misskey and any other ActivityPub server can then follow it. Follow a forum and new public topics and replies show up on your home timeline. Follow a blog and each published post arrives as an article. Replies and likes sent back from those servers wait in a queue until your staff approve them.

Federation is off by default for both. It is a choice an owner makes: once a post has gone out to another server, that server keeps its own copy.

What federates

Space

Accounts

What followers receive

Actor type

Community (forum)

One for the forum, plus one for each public category

Every new public topic and every new public reply, from the forum account. A category's account also passes on (Announce) each new topic filed in it

Group

Blog

One for the blog. Its categories are not accounts

Each post when it is published, as an Article

Person

The actor types are deliberate. Lemmy-style software treats a Group as a community its members post into, which is what a forum is. Mastodon shows a Group as an account whose posts you follow. A blog is somebody you follow, not somewhere you post, so it is a Person.

Only what a signed-out reader could read ever leaves. A post is sent, and answered at its own address, only when all of these are true:

  • the space federates and is public (a private space, or one behind a reader sign-in gate, never federates, whatever its switch says);

  • the topic is published and listed (drafts and unlisted topics stay home);

  • its category is visible to signed-out readers;

  • the post is visible, is not a staff whisper, and is not a moderator's small-action line ("closed this topic").

Anything that fails these answers 404, exactly as if it did not exist, so another server cannot even learn that a private post is there. A category that signed-out readers cannot see has no account at all.

What another server sees

A forum topic or reply is a Note:

  • the topic's title, linked to its page on your forum (a reply links to its own #post-N anchor);

  • the author's name;

  • an excerpt of the post (up to 500 characters of plain text, not the whole post);

  • the topic's tags as hashtags.

A reply carries inReplyTo pointing at the topic's opening post, so servers thread it under the topic.

A blog post is an Article with the post's whole body, rendered the way your reader renders it, with pictures served through the image proxy. A body over 60,000 characters goes out as its summary and a link instead, because Mastodon refuses very large objects. The article carries the title, summary, author name, link, tags and the date it was published (not the date the draft was begun). A scheduled post federates when it publishes.

Edits and deletions made after a post went out are not sent on. Followers keep the version they received, linked to the live page.

Handles

A handle is built from slugs, so it is unique without any registration:

Account

Handle

A forum

@<org-slug>.<space-slug>@forumulate.com

A forum category

@<org-slug>.<space-slug>.<category-slug>@forumulate.com

A blog

@<org-slug>.<space-slug>@forumulate.com

For example, the acme organization's community forum is @acme.community@forumulate.com, and its Announcements category is @acme.community.announcements@forumulate.com. Characters outside a-z 0-9 _ - in a slug become -.

Renaming the organization, the space or a category changes the handle. Existing followers stay attached to the account (followers are stored against the account, not the handle), but people who saved the old handle will not find it.

Readers find the handle on the forum's About page and on each category's page, as "Follow on the fediverse" with a copy button. The studio's Fediverse sheet lists every handle with its actor URL. If a server cannot find an account by its handle, paste the actor URL into that server's search box instead; Mastodon and most others resolve an account from its URL.

curl -sS -A 'my-app/1.0' \
  "https://forumulate.com/api/public/orgs/$ORG/spaces/$SPACE/forum-federation?category=announcements"
# { "handle": "@acme.community.announcements@forumulate.com", "actorUrl": "https://forumulate.com/api/public/activitypub/o/…/actors/…" }

That route answers { "handle": null, "actorUrl": null } for a space that does not federate. A blog answers it too, and ?category= always gives null on a blog because a blog is one account.

Turn it on

A forum

Federation is the forum behaviour key federation. Its organization default is community.forum_federation, and like every behaviour key a space's own choice wins (space, then organization, then off).

In the studio: open the forum, Forum behaviour, and switch on Federate on the fediverse (ActivityPub). Or through the API:

curl -sS -A 'my-app/1.0' -H "Authorization: Bearer $FORUMULATE_TOKEN" \
  -X PATCH -H 'content-type: application/json' \
  -d '{"federation": true, "federationReplies": "hold"}' \
  https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/forum/behaviour

To make it the default for every forum in the organization:

curl -sS -A 'my-app/1.0' -H "Authorization: Bearer $FORUMULATE_TOKEN" \
  -X PUT -H 'content-type: application/json' -d '{"value": true}' \
  https://forumulate.com/api/orgs/$ORG/settings/community.forum_federation

Sending {"federation": null} to the forum's behaviour door puts the space back under the organization default.

A blog

A blog has its own switch in its community settings, federation, with the organization default community.blog_federation (off). In the studio: open the blog, Comments and community, and switch on On the fediverse (ActivityPub). Or:

curl -sS -A 'my-app/1.0' -H "Authorization: Bearer $FORUMULATE_TOKEN" \
  -X PATCH -H 'content-type: application/json' \
  -d '{"federation": true, "federationReplies": "hold"}' \
  https://forumulate.com/api/orgs/$ORG/spaces/$SPACE/blog/settings

null on either field follows the organization again.

The settings

Setting

Where

Default

What it does

federation

forum behaviour (…/forum/behaviour); org default community.forum_federation

false

The forum and each public category become fediverse accounts

federationReplies

forum behaviour; org default community.forum_federation_replies

hold

hold: replies and likes from other servers wait in the queue for staff. ignore: nothing that arrives is kept

federation

blog settings (…/blog/settings); org default community.blog_federation

false

The blog becomes one fediverse account

federationReplies

blog settings

hold

As above; an approved reply becomes a blog comment

Accounts and their signing keys are created the first time another server asks for them, so switching federation on costs nothing until someone follows.

Switching federation off takes the accounts away at once: every address under them answers 404, nothing more is sent, and the queue stops filling. Followers stay recorded, so switching it back on resumes delivery to them.

What comes back: the federation queue

Other servers can tell your forum or blog four things:

They send

What happens

Follow (or Undo)

Accepted automatically, the way a public account works. Followers are counted per account

A reply to one of your public posts

Held in the queue (or dropped under ignore)

A like of one of your public posts

Held in the queue (or dropped under ignore)

Delete of their account, or of a reply they sent

Their follows are removed, or their held reply is rejected

Everything else is acknowledged and dropped. Nothing from another server is ever published on the say-so of that server.

The studio's Fediverse sheet (on the forum, and on a federated blog) has three tabs: Waiting, Approved and Rejected. Each item shows the remote account's name and handle, the topic it answers and its text. Staff with forum.moderate approve or reject it.

  • An approved reply becomes a reply in the thread, under the post it answers. It is attributed by name to the remote account ("Ana (@ana@example.social)") and ends with a link back to the original. It is not a member's post: it has no profile and earns no reputation, because a stranger's words must not carry a member's standing. It is not federated back out (it already lives on the fediverse). On a blog it is a comment on the post.

  • An approved like becomes a reaction: the forum's heart where its palette has one, otherwise its first reaction that is not a vote (👍 and 👎 are votes and need an account). The same remote account liking twice counts once.

  • Only the remote post's text is kept. Its HTML, images and scripts are never stored.

A reply cannot be approved into a closed or archived topic, or onto a blog post whose comments were closed while it waited (409). On a blog, a reply to a post with comments switched off, or a like where reactions are off, is dropped on arrival instead of being queued.

# The forum's accounts, handles and follower counts
GET  /orgs/{orgId}/spaces/{spaceId}/federation
# The queue: ?status=pending (default) | approved | rejected — newest 100
GET  /orgs/{orgId}/spaces/{spaceId}/federation/inbound
POST /orgs/{orgId}/spaces/{spaceId}/federation/inbound/{itemId}/approve   # { "postId": "…" | null }
POST /orgs/{orgId}/spaces/{spaceId}/federation/inbound/{itemId}/reject    # { "ok": true }

Moderation and blocking

The queue is the gate: nothing a remote account writes reaches your pages until a person approves it. There is no per-server block list. To stop everything that arrives while keeping your posts going out, set federationReplies to ignore. To leave the fediverse entirely, switch federation off.

A remote reply you approved is an ordinary reply from then on: hide, delete or move it with the usual moderation tools.

Who follows an account is the follower's business, so the public followers collection publishes only the count. Staff see the counts in the Fediverse sheet.

Security

  • Every inbox delivery is verified. The HTTP Signature must cover the request line, host, date and digest; the digest must match the exact bytes received; the date must be within an hour; and the signing key must belong to the account the activity claims to come from. Anything less is a 401.

  • Remote fetches are guarded. Looking up a remote account goes through the same outbound guard as link previews (no private or internal addresses), is time-boxed, follows no redirects and reads at most 256 KB. Our fetches are signed, so servers in "authorized fetch" mode answer them.

  • Delivery is queued. Each post is delivered once per receiving server (a server's shared inbox takes all its users at once), retried with backoff for a while when a server is down, and a server that answers 410 Gone for a follower loses that follower. A delivery problem never fails the post that caused it.

The addresses

Everything is served under the public API:

Address

What it is

/api/public/activitypub/webfinger?resource=acct:…

WebFinger for a handle

/api/public/activitypub/host-meta, /api/public/activitypub/nodeinfo

Discovery documents for older clients and fediverse directories

/api/public/activitypub/o/{orgId}/actors/{actorId}

An account (/outbox, /followers, /following, /inbox beneath it)

/api/public/activitypub/o/{orgId}/objects/{postId}

One post as a Note or Article

/api/public/activitypub/o/{orgId}/inbox

The organization's shared inbox

The outbox pages newest first, 20 to a page (?page=N), and holds the account's public topics (or, for a blog, its public posts). A category account's outbox holds its topics as Announces of the forum's objects.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go clients are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP call_api tool. Where a CLI command or MCP tool exists too, the last column names it. Operation names link to the API reference.

Activitypub

Operation

Request

What it does

Shortcuts

Activitypub_webfinger

GET /public/activitypub/webfinger

Get the webfinger

client.activitypub.webfinger()

Activitypub_hostMeta

GET /public/activitypub/host-meta

Get the host meta

client.activitypub.hostMeta()

Activitypub_nodeinfoIndex

GET /public/activitypub/nodeinfo

Get the nodeinfo

client.activitypub.nodeinfoIndex()

Activitypub_nodeinfo

GET /public/activitypub/nodeinfo/2.1

Get the 2 1

client.activitypub.nodeinfo()

Activitypub_actor

GET /public/activitypub/o/{orgId}/actors/{actorId}

Get the actor

client.activitypub.actor()

Activitypub_outbox

GET /public/activitypub/o/{orgId}/actors/{actorId}/outbox

Get the outbox

client.activitypub.outbox()

Activitypub_followers

GET /public/activitypub/o/{orgId}/actors/{actorId}/followers

List followers

client.activitypub.followers()

Activitypub_following

GET /public/activitypub/o/{orgId}/actors/{actorId}/following

Get the following

client.activitypub.following()

Activitypub_object

GET /public/activitypub/o/{orgId}/objects/{postId}

Get the object

client.activitypub.object()

Activitypub_actorInbox

POST /public/activitypub/o/{orgId}/actors/{actorId}/inbox

Create the inbox (actors)

client.activitypub.actorInbox()

Activitypub_sharedInbox

POST /public/activitypub/o/{orgId}/inbox

Create the inbox

client.activitypub.sharedInbox()

Federation

Operation

Request

What it does

Shortcuts

Federation_status

GET /orgs/{orgId}/spaces/{spaceId}/federation

Get the federation in a space

client.federation.status()

Federation_inbound

GET /orgs/{orgId}/spaces/{spaceId}/federation/inbound

Get the inbound in a space

client.federation.inbound()

Federation_approve

POST /orgs/{orgId}/spaces/{spaceId}/federation/inbound/{itemId}/approve

Approve the inbound

client.federation.approve()

Federation_reject

POST /orgs/{orgId}/spaces/{spaceId}/federation/inbound/{itemId}/reject

Reject the inbound

client.federation.reject()

The Forumulate teamUpdated