ActivityPub federation for forums and blogs
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 ( |
|
Blog | One for the blog. Its categories are not accounts | Each post when it is published, as an |
|
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-Nanchor);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 |
|
A forum category |
|
A blog |
|
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/behaviourTo 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_federationSending {"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/settingsnull on either field follows the organization again.
The settings
Setting | Where | Default | What it does |
|---|---|---|---|
| forum behaviour ( |
| The forum and each public category become fediverse accounts |
| forum behaviour; org default |
|
|
| blog settings ( |
| The blog becomes one fediverse account |
| blog settings |
| 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 |
A like of one of your public posts | Held in the queue (or dropped under |
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 Gonefor 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 |
|---|---|
| WebFinger for a handle |
| Discovery documents for older clients and fediverse directories |
| An account ( |
| One post as a |
| 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 |
|---|---|---|---|
| Get the webfinger |
| |
| Get the host meta |
| |
| Get the nodeinfo |
| |
| Get the 2 1 |
| |
| Get the actor |
| |
| Get the outbox |
| |
| List followers |
| |
| Get the following |
| |
| Get the object |
| |
| Create the inbox (actors) |
| |
| Create the inbox |
|
Federation
Operation | Request | What it does | Shortcuts |
|---|---|---|---|
| Get the federation in a space |
| |
| Get the inbound in a space |
| |
| Approve the inbound |
| |
| Reject the inbound |
|