- What is ActivityPub?
- What is Pubby?
- Installation
- Quick Start
- Publishing Content
- Key Management
- Custom Storage
- Configuration Reference
- Rendering Interactions
- Rate Limiting
- Interaction Callbacks
- API
- Tests
- Development
- License
A general-purpose Python library to add ActivityPub federation support to your website.
ActivityPub is a W3C standard for decentralized social networking. Servers exchange JSON-LD activities (posts, likes, follows, boosts) over HTTP, enabling federation across platforms like Mastodon, Pleroma, Misskey, and others. It's the protocol that powers the Fediverse.
Pubby is a framework-agnostic library that handles the ActivityPub plumbing so you can focus on your app:
- Inbox processing — receive and dispatch Follow, Like, Announce, Create, Update, Delete activities
- Outbox delivery — concurrent fan-out to follower inboxes with retry and shared-inbox deduplication
- HTTP Signatures — sign outgoing requests and verify incoming ones
(draft-cavage, using
cryptographydirectly — nohttpsigdependency) - Discovery — WebFinger and NodeInfo 2.1 endpoints
- Interaction storage — followers, interactions, activities, actor cache
- Framework adapters — Flask, FastAPI, Tornado
- Storage adapters — SQLAlchemy (any supported database) and file-based JSON
Base install:
pip install pubbyWith extras:
pip install "pubby[db,flask]" # SQLAlchemy + Flask
pip install "pubby[db,fastapi]" # SQLAlchemy + FastAPI
pip install "pubby[db,tornado]" # SQLAlchemy + TornadoAvailable extras: db, flask, fastapi, tornado.
pip install "pubby[db,flask]"from flask import Flask
from pubby import ActivityPubHandler
from pubby.crypto import generate_rsa_keypair, export_private_key_pem
from pubby.storage.adapters.db import init_db_storage
from pubby.server.adapters.flask import bind_activitypub
app = Flask(__name__)
storage = init_db_storage("sqlite:////tmp/pubby.db")
# Generate a keypair (persist this — don't regenerate on restart!)
private_key, _ = generate_rsa_keypair()
handler = ActivityPubHandler(
storage=storage,
actor_config={
"base_url": "https://example.com",
"username": "blog",
"name": "My Blog",
"summary": "A blog with ActivityPub support",
},
private_key=private_key,
)
bind_activitypub(app, handler)
app.run()pip install "pubby[db,fastapi]"from fastapi import FastAPI
from pubby import ActivityPubHandler
from pubby.crypto import generate_rsa_keypair
from pubby.storage.adapters.db import init_db_storage
from pubby.server.adapters.fastapi import bind_activitypub
app = FastAPI()
storage = init_db_storage("sqlite:////tmp/pubby.db")
private_key, _ = generate_rsa_keypair()
handler = ActivityPubHandler(
storage=storage,
actor_config={
"base_url": "https://example.com",
"username": "blog",
"name": "My Blog",
"summary": "A blog with ActivityPub support",
},
private_key=private_key,
)
bind_activitypub(app, handler)pip install "pubby[db,tornado]"from tornado.web import Application
from tornado.ioloop import IOLoop
from pubby import ActivityPubHandler
from pubby.crypto import generate_rsa_keypair
from pubby.storage.adapters.db import init_db_storage
from pubby.server.adapters.tornado import bind_activitypub
app = Application()
storage = init_db_storage("sqlite:////tmp/pubby.db")
private_key, _ = generate_rsa_keypair()
handler = ActivityPubHandler(
storage=storage,
actor_config={
"base_url": "https://example.com",
"username": "blog",
"name": "My Blog",
"summary": "A blog with ActivityPub support",
},
private_key=private_key,
)
bind_activitypub(app, handler)
app.listen(8000)
IOLoop.current().start()All adapters register the same endpoints:
| Method | Path | Description |
|---|---|---|
GET |
/.well-known/webfinger |
WebFinger discovery |
GET |
/.well-known/nodeinfo |
NodeInfo discovery |
GET |
/nodeinfo/2.1 |
NodeInfo 2.1 document |
GET |
/ap/actor |
Actor profile (JSON-LD) |
POST |
/ap/inbox |
Receive activities |
GET |
/ap/outbox |
Outbox collection |
GET |
/ap/followers |
Followers collection |
GET |
/ap/following |
Following collection |
The /ap prefix is configurable via the prefix parameter on bind_activitypub.
Pubby ships a read-only subset of the Mastodon REST API so that Mastodon-compatible clients and crawlers can discover the instance, look up the actor, list published statuses, and inspect followers.
Call bind_mastodon_api alongside bind_activitypub:
from pubby.server.adapters.flask import bind_activitypub
from pubby.server.adapters.flask_mastodon import bind_mastodon_api
bind_activitypub(app, handler)
bind_mastodon_api(
app,
handler,
title="My Blog", # instance title (default: actor name)
description="A cool blog", # instance description (default: actor summary)
contact_email="me@example.com", # optional contact e-mail
software_name="MyApp", # shown in /api/v1/instance version string
software_version="1.0.0",
)The same function is available for all three frameworks:
pubby.server.adapters.flask_mastodon.bind_mastodon_apipubby.server.adapters.fastapi_mastodon.bind_mastodon_apipubby.server.adapters.tornado_mastodon.bind_mastodon_api
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/instance |
Instance metadata (v1) |
GET |
/api/v2/instance |
Instance metadata (v2) |
GET |
/api/v1/instance/peers |
Peer domains from followers |
GET |
/api/v1/accounts/lookup |
Resolve acct:user@domain → Account |
GET |
/api/v1/accounts/:id |
Account by ID ("1" = local actor) |
GET |
/api/v1/accounts/:id/statuses |
Paginated statuses for account |
GET |
/api/v1/accounts/:id/followers |
Paginated followers list |
GET |
/api/v1/statuses/:id |
Single status by ID |
GET |
/nodeinfo/2.0 |
NodeInfo 2.0 alias |
GET |
/nodeinfo/2.0.json |
NodeInfo 2.0 .json alias |
GET |
/nodeinfo/2.1.json |
NodeInfo 2.1 .json alias |
| Parameter | Type | Default | Description |
|---|---|---|---|
app |
framework app | required | Flask / FastAPI / Tornado application |
handler |
ActivityPubHandler |
required | The handler instance |
title |
str |
actor name | Instance title |
description |
str |
actor summary | Instance description |
contact_email |
str |
"" |
Contact e-mail |
software_name |
str |
handler's software_name |
Software name in version string |
software_version |
str |
handler's software_version |
Software version string |
- The local actor always has account ID
"1". - Status IDs are URL-safe base64 encodings of the AP object URL, making them deterministic and reversible.
Publish an article to all followers:
from pubby import Object
article = Object(
id="https://example.com/posts/hello-world",
type="Article",
name="Hello World",
content="<p>My first federated post!</p>",
url="https://example.com/posts/hello-world",
attributed_to="https://example.com/ap/actor",
)
handler.publish_object(article)To update or delete:
# Update
handler.publish_object(updated_article, activity_type="Update")
# Delete
handler.publish_object(deleted_article, activity_type="Delete")Delivery is concurrent (configurable via max_delivery_workers, default 10)
with automatic retry and exponential backoff on failure.
Important: your RSA keypair is your server's identity. Persist it — if you regenerate it, other servers won't be able to verify your signatures.
from pubby.crypto import (
generate_rsa_keypair,
export_private_key_pem,
load_private_key,
)
# Generate once and save
private_key, public_key = generate_rsa_keypair()
pem = export_private_key_pem(private_key)
with open("/path/to/private_key.pem", "w") as f:
f.write(pem)
# Load on startup
handler = ActivityPubHandler(
storage=storage,
actor_config={...},
private_key_path="/path/to/private_key.pem",
)If you don't want to use SQLAlchemy or the file-based adapter, extend
ActivityPubStorage:
from pubby import ActivityPubStorage, Follower, Interaction
class MyStorage(ActivityPubStorage):
def store_follower(self, follower: Follower):
...
def remove_follower(self, actor_id: str):
...
def get_followers(self) -> list[Follower]:
...
def store_interaction(self, interaction: Interaction):
...
def delete_interaction(self, source_actor_id: str, target_resource: str, interaction_type: str):
...
def get_interactions(self, target_resource: str | None = None, interaction_type: str | None = None) -> list[Interaction]:
...
def get_interactions_mentioning(self, actor_url: str, interaction_type: str | None = None) -> list[Interaction]:
... # Optional: returns interactions where actor_url is in mentioned_actors
def get_interaction_by_object_id(self, object_id: str, status: InteractionStatus = InteractionStatus.CONFIRMED) -> Interaction | None:
... # Optional: look up interaction by remote object URL
def store_activity(self, activity_id: str, activity_data: dict):
...
def get_activities(self, limit: int = 20, offset: int = 0) -> list[dict]:
...
def cache_remote_actor(self, actor_id: str, actor_data: dict):
...
def get_cached_actor(self, actor_id: str, max_age_seconds: int = 86400) -> dict | None:
...
handler = ActivityPubHandler(
storage=MyStorage(),
actor_config={...},
private_key=private_key,
)For apps that don't need a database (e.g. static-site generators):
from pubby.storage.adapters.file import FileActivityPubStorage
storage = FileActivityPubStorage(data_dir="/var/lib/myapp/activitypub")Data is stored as JSON files in a structured directory layout, with
thread-safe access via RLock per resource.
Automatic schema migrations: On initialization, the storage checks a
.schema_version file and automatically runs any pending migrations
(e.g., rebuilding indexes). To disable this:
storage = FileActivityPubStorage(data_dir="...", auto_migrate=False)| Parameter | Type | Default | Description |
|---|---|---|---|
storage |
ActivityPubStorage |
required | Storage backend |
actor_config |
dict |
required | Actor configuration (see below) |
private_key |
key / str / bytes | — | RSA private key |
private_key_path |
str / Path | — | Path to PEM private key file |
on_interaction_received |
Callable |
None |
Callback on new interaction |
webfinger_domain |
str |
from base_url |
Domain for acct: URIs |
user_agent |
str |
"pubby/0.0.1" |
Outgoing User-Agent |
http_timeout |
float |
15.0 |
HTTP request timeout (seconds) |
max_retries |
int |
3 |
Delivery retry attempts |
max_delivery_workers |
int |
10 |
Concurrent delivery threads |
auto_approve_quotes |
bool |
True |
Auto-send QuoteAuthorization for incoming quotes |
store_local_only |
bool |
False |
Only store interactions targeting local URLs or mentioning the actor |
local_base_urls |
list[str] |
None |
Base URLs considered "local" (defaults to actor's base URL) |
software_name |
str |
"pubby" |
NodeInfo software name |
software_version |
str |
"0.0.1" |
NodeInfo software version |
async_delivery |
bool |
True |
Run delivery fan-out in background thread (non-blocking) |
Pass an ActorConfig dataclass (recommended) or a plain dict (backwards compatible):
from pubby import ActorConfig
config = ActorConfig(
base_url="https://example.com",
username="blog",
name="My Blog",
summary="A blog with ActivityPub support",
)
handler = ActivityPubHandler(storage=storage, actor_config=config, ...)| Field | Type | Default | Description |
|---|---|---|---|
base_url |
str |
required | Public base URL of your site |
username |
str |
"blog" |
Actor username (WebFinger handle) |
name |
str |
username | Display name shown on remote instances |
summary |
str |
"" |
Actor bio/description (HTML allowed) |
icon_url |
str |
"" |
Avatar image URL |
actor_path |
str |
"/ap/actor" |
URL path to the actor endpoint |
type |
str |
"Person" |
ActivityPub actor type (Person, Application, Service) |
manually_approves_followers |
bool |
False |
Require explicit follow approval |
attachment |
list[dict] |
[] |
Profile metadata fields (see below) |
Mastodon and other Fediverse software display profile metadata fields (the
key-value pairs shown on a user's profile page). These are passed as
PropertyValue attachments in the actor config:
handler = ActivityPubHandler(
storage=storage,
actor_config={
"base_url": "https://example.com",
"username": "blog",
"name": "My Blog",
"summary": "A blog with ActivityPub support",
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": '<a href="https://example.com" rel="me">https://example.com</a>',
},
],
},
private_key=private_key,
)For Mastodon's green verified-link checkmark to appear, the linked page must
contain a <link rel="me" href="https://example.com/ap/actor"> tag pointing
back to the actor URL.
Pubby includes a Jinja2-based renderer for displaying interactions (replies, likes, boosts) on your pages:
from pubby import InteractionType
interactions = handler.storage.get_interactions(
target_resource="https://example.com/posts/hello-world"
)
html = handler.render_interactions(interactions)Then in your template:
<article>
<h1>Hello World</h1>
<p>My first federated post!</p>
</article>
<section class="interactions">
{{ interactions_html }}
</section>render_interactions returns a safe Markup object with theme-aware styling.
You can also pass a custom Jinja2 template.
Protect your inbox with the built-in per-IP sliding window rate limiter:
from pubby import RateLimiter
from pubby.server.adapters.flask import bind_activitypub
rate_limiter = RateLimiter(max_requests=100, window_seconds=60)
bind_activitypub(app, handler, rate_limiter=rate_limiter)Get notified when interactions arrive:
from pubby import Interaction
def on_interaction(interaction: Interaction):
print(f"New {interaction.interaction_type}: {interaction.source_actor_id}")
handler = ActivityPubHandler(
storage=storage,
actor_config={...},
private_key=private_key,
on_interaction_received=on_interaction,
)Only publicly addressed interactions (those with
https://www.w3.org/ns/activitystreams#Public in to or cc) are persisted
to storage. This includes both public and unlisted posts. Private/direct
messages and followers-only posts are not stored, preventing them from
appearing in public contexts like blog comments.
However, the on_interaction_received callback is still invoked for all
interactions, including private ones. This allows applications to send
notifications (e.g., email alerts) for direct messages without exposing them
publicly.
Typed configuration for an ActivityPub actor (replaces the old plain-dict approach):
from pubby import ActorConfig
config = ActorConfig(
base_url="https://example.com",
username="blog",
name="My Blog",
summary="A federated blog",
type="Person",
)See actor_config in the Configuration Reference for the full field table.
Represents an ActivityPub object (Note, Article, etc.):
from pubby import Object
obj = Object(
id="https://example.com/posts/1",
type="Note",
content="<p>Hello!</p>",
url="https://example.com/posts/1",
attributed_to="https://example.com/ap/actor",
media_type="text/html", # optional, serialized as "mediaType" in JSON-LD
quote_control={"quotePolicy": "public"}, # optional, serialized as "quoteControl"
quote_policy="public", # optional, serialized as "quotePolicy"
interaction_policy={
"canQuote": {
"automaticApproval": ["https://www.w3.org/ns/activitystreams#Public"],
"manualApproval": [],
},
}, # optional, serialized as "interactionPolicy"
)Key fields: id, type, name, content, url, attributed_to,
published, updated, summary, to, cc, tag, media_type,
quote_control, quote_policy, interaction_policy.
Represents a stored interaction from the fediverse (reply, like, boost, mention, quote):
from pubby import Interaction, InteractionType, InteractionStatus
interaction = Interaction(
source_actor_id="https://mastodon.social/users/alice",
target_resource="https://example.com/posts/1",
interaction_type=InteractionType.REPLY,
content="<p>Great post!</p>",
author_name="Alice",
author_url="https://mastodon.social/@alice",
mentioned_actors=["https://example.com/ap/actor"],
)| Field | Type | Description |
|---|---|---|
source_actor_id |
str |
Actor URL of the interaction author |
target_resource |
str |
URL of the resource being interacted with |
interaction_type |
InteractionType |
REPLY, LIKE, BOOST, MENTION, or QUOTE |
activity_id |
str |
ActivityPub activity ID |
object_id |
str |
ActivityPub object ID (for replies/quotes) |
content |
str |
HTML content (for replies/quotes/mentions) |
author_name |
str |
Display name of the author |
author_url |
str |
Profile URL of the author |
author_photo |
str |
Avatar URL of the author |
published |
datetime |
When the interaction was published |
status |
InteractionStatus |
PENDING, CONFIRMED, or DELETED |
metadata |
dict |
Additional data (e.g. raw_object) |
mentioned_actors |
list[str] |
Actor URLs mentioned in this interaction |
Mastodon reads quote permissions from the ActivityPub object's
interactionPolicy.canQuote field. To allow public quoting without
approval, set automaticApproval to the public collection and leave
manualApproval empty:
obj = Object(
...,
interaction_policy={
"canQuote": {
"automaticApproval": ["https://www.w3.org/ns/activitystreams#Public"],
"manualApproval": [],
}
},
)If you include a non-empty manualApproval, Mastodon will create a
pending quote request instead of immediately allowing it.
Advertising interactionPolicy.canQuote is advisory only. Mastodon
and other servers won't clear the "pending" state on a remote quote
until they can verify a QuoteAuthorization stamp from the quoted
post's author.
The approval flow defined by FEP-044f works as follows:
- The remote server sends a
QuoteRequestactivity to your inbox. - Pubby responds with an
Acceptactivity whoseresultpoints to a dereferenceableQuoteAuthorizationURL. - The remote server fetches the
QuoteAuthorizationat that URL and clears the pending state.
Pubby handles this automatically. The QuoteAuthorization objects are
stored and served at <prefix>/quote_authorizations/<id>.
Additionally, incoming Create activities that contain a quote,
quoteUrl, or _misskey_quote field are stored as
InteractionType.QUOTE interactions.
This behaviour is controlled by the auto_approve_quotes parameter
(default True). Set it to False to ignore QuoteRequest activities:
handler = ActivityPubHandler(
...,
auto_approve_quotes=False,
)A resolved @user@domain mention:
from pubby import Mention
m = Mention(username="alice", domain="mastodon.social", actor_url="https://mastodon.social/users/alice")
m.acct # "@alice@mastodon.social"
m.to_tag() # {"type": "Mention", "href": "https://mastodon.social/users/alice", "name": "@alice@mastodon.social"}Resolve the ActivityPub actor URL for @username@domain via
WebFinger (RFC 7033). Returns the
self link with an application/* media type, or falls back to
https://{domain}/@{username} on failure.
from pubby import resolve_actor_url
url = resolve_actor_url("alice", "mastodon.social")
# "https://mastodon.social/@alice"
url = resolve_actor_url("bob", "pleroma.example")
# "https://pleroma.example/users/bob"This works across all ActivityPub implementations (Mastodon, Pleroma, Akkoma, Misskey, etc.) since WebFinger is the standard discovery mechanism.
Find all @user@domain patterns in a text string, resolve each via WebFinger,
and return a list of Mention objects. Duplicates are deduplicated
(case-insensitive).
from pubby import extract_mentions
text = "Hello @alice@mastodon.social and @bob@pleroma.example!"
mentions = extract_mentions(text)
# Build ActivityPub tag array and cc list:
tags = [m.to_tag() for m in mentions]
cc = [m.actor_url for m in mentions]Publish an Object to all followers. Fan-out is concurrent with automatic
retry and shared-inbox deduplication.
handler.publish_object(article) # Create
handler.publish_object(updated_article, activity_type="Update")
handler.publish_object(deleted_article, activity_type="Delete")Publish a pre-built activity dict as-is, without wrapping it in a
Create/Update envelope. Use this for activity types that are not Object
wrappers — Like, Announce, Undo, Follow, etc.
The OutboxProcessor provides builders for common activity types:
# Like a remote post
like = handler.outbox.build_like_activity("https://remote.example.com/post/42")
handler.publish_activity(like)
# Boost (Announce) a remote post
boost = handler.outbox.build_announce_activity("https://remote.example.com/post/42")
handler.publish_activity(boost)
# Undo the like
undo = handler.outbox.build_undo_activity(like)
handler.publish_activity(undo)Available builders on handler.outbox:
| Builder | Returns |
|---|---|
build_like_activity(object_url, *, activity_id=None, published=None) |
Like activity dict |
build_announce_activity(object_url, *, activity_id=None, published=None) |
Announce (boost) activity dict |
build_undo_activity(inner_activity) |
Undo activity dict wrapping any activity |
build_undo_activity is intentionally generic — it works for
Undo Like, Undo Announce, Undo Follow, etc.
Push the current actor profile to all followers. Call this after changing any actor properties (name, summary, icon, attachment/fields) so remote instances refresh their cached copy. This is the standard mechanism used by Mastodon when a user edits their profile.
handler.publish_actor_update()The method builds an Update activity whose object is the full actor
document, and fans it out to every follower inbox.
Abstract base class. Built-in adapters:
pubby.storage.adapters.db.init_db_storage(url)— SQLAlchemy (any DB)pubby.storage.adapters.file.FileActivityPubStorage(data_dir)— JSON files
See Custom Storage for implementing your own.
Look up an interaction by its remote object URL (e.g., a Mastodon status URL). Useful when you need to find an interaction without knowing its target resource:
# Find who sent a particular reply
interaction = storage.get_interaction_by_object_id(
"https://mastodon.social/users/alice/statuses/123456"
)
if interaction:
print(f"Reply from: {interaction.source_actor_id}")Both storage adapters implement this efficiently:
- DB storage: SQL query on the indexed
object_idcolumn - File storage: Uses an
_object_ids/index directory for O(1) lookup
To enable get_interactions_mentioning() with the DB adapter, add the
DbInteractionMention model to your schema:
from sqlalchemy.orm import declarative_base
from pubby.storage.adapters.db import (
DbActivityPubStorage,
DbFollower,
DbInteraction,
DbInteractionMention,
DbActivity,
DbActorCache,
)
Base = declarative_base()
class InteractionMention(Base, DbInteractionMention):
__tablename__ = "interaction_mentions"
# ... other models ...
storage = DbActivityPubStorage(
engine=engine,
follower_model=Follower,
interaction_model=Interaction,
activity_model=Activity,
actor_cache_model=ActorCache,
interaction_mention_model=InteractionMention, # Enable mention index
session_factory=session_factory,
)Backfill mentioned_actors for existing interactions by extracting mentions
from the raw_object stored in metadata. Useful after upgrading to a version
with mention indexing:
from pubby.storage import backfill_mentions
from pubby.storage.adapters.file import FileActivityPubStorage
storage = FileActivityPubStorage("/path/to/data")
# Preview changes
stats = backfill_mentions(storage, dry_run=True)
print(stats)
# {'scanned': 42, 'updated': 15, 'skipped_no_metadata': 10, ...}
# Apply changes
stats = backfill_mentions(storage)Currently supports FileActivityPubStorage. For DB storage, run a direct SQL
migration to populate the interaction_mentions table from existing data.
Backfill the _object_ids/ index for existing interactions. Required after
upgrading to enable get_interaction_by_object_id() for pre-existing data:
from pubby.storage import backfill_object_id_index
from pubby.storage.adapters.file import FileActivityPubStorage
storage = FileActivityPubStorage("/path/to/data")
# Preview changes
stats = backfill_object_id_index(storage, dry_run=True)
print(stats)
# {'scanned': 100, 'indexed': 85, 'skipped_no_object_id': 10, ...}
# Apply changes
stats = backfill_object_id_index(storage)Only needed for FileActivityPubStorage. DB storage uses SQL indexes automatically.
from pubby.crypto import generate_rsa_keypair, export_private_key_pem, load_private_key
private_key, public_key = generate_rsa_keypair()
pem = export_private_key_pem(private_key)
private_key = load_private_key("/path/to/key.pem")pip install -e ".[test]"
pytest testspip install -e ".[dev]"
pre-commit install
pre-commit run --all-files