Publishing to the Fediverse
This tutorial teaches you how to publish content from your application to the Fediverse by creating activities and delivering them to follower inboxes. You will learn to create objects and activities, address them properly, and use the notification system to deliver activities to remote servers.
By the end of this tutorial, you will understand the complete publishing workflow: creating content objects, wrapping them in activities, and delivering those activities to all followers' inboxes using HTTP-signed requests.
Understanding Publishing
Publishing to the Fediverse means creating ActivityPub activities and delivering them to remote inboxes. When a user creates a journal entry, you generate a Create activity and send it to everyone who follows that user. When they update content, you send an Update activity. When they delete something, you send a Delete activity.
The publishing workflow has three main steps:
- Create the content object - An ObjectContext representing the actual content (a Note, Article, Image, etc.)
- Create the activity - An ActivityContext that wraps the object and describes what happened (Create, Update, Delete, etc.)
- Deliver to followers - Iterate through follower inboxes and create Notification records that trigger HTTP delivery
The toolkit handles the HTTP delivery automatically. You create the activities and notifications, and the send_notification task handles signing requests and POSTing to remote servers.
Prerequisites
This tutorial assumes you have completed the previous tutorial on handling incoming activities. You should have actors with inboxes and outboxes already set up, and the catch-all URL pattern configured.
Creating Content Objects
Start by creating an ObjectContext that represents your content. For a journal application, this might be a Note or Article. The object includes the content itself, attribution, and timestamps.
Update your journal entry creation to include ActivityPub objects. In journal/models.py:
from django.db import models
from django.contrib.auth.models import User
from django.utils import timezone
from activitypub.models import (
ObjectContext,
ActivityContext,
CollectionContext,
Reference,
Domain,
)
class JournalEntry(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
reference = models.OneToOneField(
Reference,
on_delete=models.CASCADE,
related_name='journal_entry'
)
created_at = models.DateTimeField(auto_now_add=True)
@classmethod
def create_entry(cls, user, content, title=None):
"""Create a journal entry with its ActivityPub representation."""
domain = Domain.get_default()
# Create the object reference
entry_ref = ObjectContext.generate_reference(domain)
# Get the user's actor
actor_ref = user.profile.actor_reference
# Create the object context
obj = ObjectContext.make(
reference=entry_ref,
type=ObjectContext.Types.NOTE,
content=content,
name=title,
published=timezone.now(),
attributed_to=actor_ref,
)
# Create the application model
entry = cls.objects.create(
user=user,
reference=entry_ref,
)
return entry
The ObjectContext.make() method creates the object and automatically generates collections for replies, likes, and shares through Django signals. You do not need to create these collections manually.
Run migrations to add the reference field:
python manage.py makemigrations
python manage.py migrate
Creating Activities
Activities describe what happened to objects. A Create activity announces new content. An Update activity announces changes. A Delete activity announces removal.
Extend the entry creation to include a Create activity:
@classmethod
def create_entry(cls, user, content, title=None):
"""Create a journal entry with its ActivityPub representation and activity."""
domain = Domain.get_default()
# Create the object reference
entry_ref = ObjectContext.generate_reference(domain)
# Get the user's actor
actor_ref = user.profile.actor_reference
# Create the object context
obj = ObjectContext.make(
reference=entry_ref,
type=ObjectContext.Types.NOTE,
content=content,
name=title,
published=timezone.now(),
attributed_to=actor_ref,
)
# Create the application model
entry = cls.objects.create(
user=user,
reference=entry_ref,
)
# Create the Create activity
activity_ref = ActivityContext.generate_reference(domain)
activity = ActivityContext.make(
reference=activity_ref,
type=ActivityContext.Types.CREATE,
actor=actor_ref,
object=entry_ref,
published=timezone.now(),
)
return entry, activity
The activity has three critical fields:
actor- Who performed the action (the user's actor reference)object- What was acted upon (the entry reference)type- What kind of action occurred (CREATE)
Adding to Outbox
Activities should be added to the actor's outbox collection. This makes them discoverable through the outbox endpoint and provides a record of what the actor has published.
@classmethod
def create_entry(cls, user, content, title=None):
"""Create a journal entry with activity and add to outbox."""
domain = Domain.get_default()
entry_ref = ObjectContext.generate_reference(domain)
actor_ref = user.profile.actor_reference
# Create object
obj = ObjectContext.make(
reference=entry_ref,
type=ObjectContext.Types.NOTE,
content=content,
name=title,
published=timezone.now(),
attributed_to=actor_ref,
)
# Create entry
entry = cls.objects.create(
user=user,
reference=entry_ref,
)
# Create activity
activity_ref = ActivityContext.generate_reference(domain)
activity = ActivityContext.make(
reference=activity_ref,
type=ActivityContext.Types.CREATE,
actor=actor_ref,
object=entry_ref,
published=timezone.now(),
)
# Add to outbox
from activitypub.models import ActorContext
actor = actor_ref.get_by_context(ActorContext)
if actor and actor.outbox:
outbox = actor.outbox.get_by_context(CollectionContext)
if outbox:
outbox.append(activity_ref)
return entry, activity
The outbox collection maintains a reverse-chronological list of activities the actor has performed. Remote servers can fetch this collection to discover the actor's history.
Activity Addressing
Activities include addressing fields that determine who should receive them. The to field indicates primary recipients. The cc field indicates courtesy copy recipients. The bcc field indicates blind copy recipients whose addresses are not disclosed.
The special URI https://www.w3.org/ns/activitystreams#Public represents public addressing. Activities addressed to Public appear in public timelines and are visible to anyone.
Add addressing to your activities:
from activitypub.schemas import AS2
@classmethod
def create_entry(cls, user, content, title=None, public=True):
"""Create a journal entry with proper addressing."""
domain = Domain.get_default()
entry_ref = ObjectContext.generate_reference(domain)
actor_ref = user.profile.actor_reference
# Create object
obj = ObjectContext.make(
reference=entry_ref,
type=ObjectContext.Types.NOTE,
content=content,
name=title,
published=timezone.now(),
attributed_to=actor_ref,
)
# Create entry
entry = cls.objects.create(
user=user,
reference=entry_ref,
)
# Create activity
activity_ref = ActivityContext.generate_reference(domain)
activity = ActivityContext.make(
reference=activity_ref,
type=ActivityContext.Types.CREATE,
actor=actor_ref,
object=entry_ref,
published=timezone.now(),
)
# Set addressing
from activitypub.models import ActorContext
actor = actor_ref.get_by_context(ActorContext)
if public:
# Public post: to=Public, cc=followers
activity.to.add(Reference.make(str(AS2.Public)))
if actor and actor.followers:
activity.cc.add(actor.followers)
else:
# Followers-only post: to=followers
if actor and actor.followers:
activity.to.add(actor.followers)
activity.save()
# Add to outbox
if actor and actor.outbox:
outbox = actor.outbox.get_by_context(CollectionContext)
if outbox:
outbox.append(activity_ref)
return entry, activity
The addressing fields are many-to-many relationships. You can address activities to individual actors, collections, or the Public constant. For public posts, you typically set to=Public and cc=followers. For followers-only posts, you set to=followers.
Delivering to Followers
The core of publishing is delivering activities to follower inboxes. The toolkit provides the followers_inboxes property on Actor, which returns a queryset of inbox References for all followers. For each inbox, you create a Notification that triggers HTTP delivery.
Add delivery to your entry creation:
from activitypub.models import Notification
from activitypub.tasks import send_notification
@classmethod
def create_entry(cls, user, content, title=None, public=True):
"""Create and publish a journal entry to followers."""
domain = Domain.get_default()
entry_ref = ObjectContext.generate_reference(domain)
actor_ref = user.profile.actor_reference
# Create object
obj = ObjectContext.make(
reference=entry_ref,
type=ObjectContext.Types.NOTE,
content=content,
name=title,
published=timezone.now(),
attributed_to=actor_ref,
)
# Create entry
entry = cls.objects.create(
user=user,
reference=entry_ref,
)
# Create activity
activity_ref = ActivityContext.generate_reference(domain)
activity = ActivityContext.make(
reference=activity_ref,
type=ActivityContext.Types.CREATE,
actor=actor_ref,
object=entry_ref,
published=timezone.now(),
)
# Set addressing
from activitypub.models import ActorContext, Actor
actor = actor_ref.get_by_context(Actor)
if public:
activity.to.add(Reference.make(str(AS2.Public)))
if actor and actor.followers:
activity.cc.add(actor.followers)
else:
if actor and actor.followers:
activity.to.add(actor.followers)
activity.save()
# Add to outbox
if actor and actor.outbox:
outbox = actor.outbox.get_by_context(CollectionContext)
if outbox:
outbox.append(activity_ref)
# Deliver to followers
if actor:
for inbox_ref in actor.followers_inboxes:
notification = Notification.objects.create(
resource=activity_ref,
sender=actor_ref,
target=inbox_ref,
)
send_notification.delay(notification_id=str(notification.id))
return entry, activity
The followers_inboxes property returns inbox References for all followers. It prefers shared inboxes when available, reducing the number of HTTP requests needed. For each inbox, you create a Notification with:
resource- The activity reference being deliveredsender- The actor reference sending the activitytarget- The inbox reference receiving the activity
The send_notification task handles the actual HTTP delivery. It serializes the activity to JSON-LD, signs the request using the sender's keypair, and POSTs to the inbox URL. The task runs asynchronously through Celery.
Follow Request Handling
Before you can deliver activities to followers, users need to follow your actors. When a remote user sends a Follow activity to your inbox, the toolkit automatically creates a FollowRequest record. The toolkit handles Follow acceptance automatically based on the actor's manually_approves_followers setting. If set to False (the default), Follow requests are accepted automatically. Once accepted, the follower is added to the actor's followers collection, and their inbox will receive future activities via actor.followers_inboxes.
Testing Publication
Test the complete publishing workflow by creating an entry and verifying delivery:
python manage.py shell
from django.contrib.auth.models import User
from journal.models import JournalEntry
user = User.objects.first()
entry, activity = JournalEntry.create_entry(
user=user,
content="Testing federation!",
title="Test Entry",
public=True
)
print(f"Created entry: {entry.reference.uri}")
print(f"Created activity: {activity.reference.uri}")
# Check outbox
from activitypub.models import CollectionContext
actor = user.profile.actor
outbox = actor.outbox.get_by_context(CollectionContext)
print(f"Outbox has {outbox.total_items} items")
# Check notifications
from activitypub.models import Notification
notifications = Notification.objects.filter(resource=activity.reference)
print(f"Created {notifications.count()} notifications")
If the user has followers, you should see notifications created for each follower's inbox. The send_notification task runs asynchronously, so check the Celery logs to verify delivery:
celery -A project worker --loglevel=info
You should see log entries showing the HTTP POST requests to follower inboxes.
Updating Content
When content changes, send an Update activity. Add an update method to your model:
def update_content(self, content, title=None):
"""Update the entry and send Update activity to followers."""
from activitypub.models import ObjectContext, ActivityContext, Actor
from activitypub.schemas import AS2
# Update the object
obj = self.reference.get_by_context(ObjectContext)
obj.content = content
if title is not None:
obj.name = title
obj.updated = timezone.now()
obj.save()
# Create Update activity
domain = Domain.get_default()
activity_ref = ActivityContext.generate_reference(domain)
actor_ref = self.user.profile.actor_reference
activity = ActivityContext.make(
reference=activity_ref,
type=ActivityContext.Types.UPDATE,
actor=actor_ref,
object=self.reference,
published=timezone.now(),
)
# Set addressing
actor = actor_ref.get_by_context(Actor)
activity.to.add(Reference.make(str(AS2.Public)))
if actor and actor.followers:
activity.cc.add(actor.followers)
activity.save()
# Add to outbox
if actor and actor.outbox:
outbox = actor.outbox.get_by_context(CollectionContext)
if outbox:
outbox.append(activity_ref)
# Deliver to followers
if actor:
for inbox_ref in actor.followers_inboxes:
notification = Notification.objects.create(
resource=activity_ref,
sender=actor_ref,
target=inbox_ref,
)
send_notification.delay(notification_id=str(notification.id))
The Update activity uses the same delivery pattern as Create. The object field references the updated object, and the activity is delivered to all followers.
Deleting Content
When content is deleted, send a Delete activity. Add a delete method:
def delete_entry(self):
"""Delete the entry and send Delete activity to followers."""
from activitypub.models import ObjectContext, ActivityContext, Actor
from activitypub.schemas import AS2
# Create Delete activity before deleting the object
domain = Domain.get_default()
activity_ref = ActivityContext.generate_reference(domain)
actor_ref = self.user.profile.actor_reference
activity = ActivityContext.make(
reference=activity_ref,
type=ActivityContext.Types.DELETE,
actor=actor_ref,
object=self.reference,
published=timezone.now(),
)
# Set addressing
actor = actor_ref.get_by_context(Actor)
activity.to.add(Reference.make(str(AS2.Public)))
if actor and actor.followers:
activity.cc.add(actor.followers)
activity.save()
# Add to outbox
if actor and actor.outbox:
outbox = actor.outbox.get_by_context(CollectionContext)
if outbox:
outbox.append(activity_ref)
# Deliver to followers
if actor:
for inbox_ref in actor.followers_inboxes:
notification = Notification.objects.create(
resource=activity_ref,
sender=actor_ref,
target=inbox_ref,
)
send_notification.delay(notification_id=str(notification.id))
# Delete the entry and object
self.reference.delete()
self.delete()
The Delete activity is sent before the object is removed. Remote servers receive the activity and can remove their cached copies of the content.
Understanding HTTP Signatures
The send_notification task signs HTTP requests using the sender's keypair. The toolkit automatically creates a keypair for each actor when the actor is created. The public key is embedded in the actor document, and the private key is stored in the SecV1Context model.
When sending a notification, the task:
- Serializes the activity to JSON-LD
- Retrieves the sender's private key
- Creates an HTTP signature header using the private key
- POSTs the activity to the inbox with the signature
Remote servers verify the signature using the public key from the actor document. This proves the activity came from the claimed sender and hasn't been tampered with.
You do not need to implement signature generation yourself. The toolkit handles it automatically through the send_notification task.
Handling Delivery Failures
Not all deliveries succeed. Remote servers might be offline, reject the activity, or return errors. The toolkit records delivery results in NotificationProcessResult.
Check delivery results:
from activitypub.models import Notification, NotificationProcessResult
notification = Notification.objects.first()
results = notification.results.all()
for result in results:
print(f"Result: {result.result}")
if result.message:
print(f"Message: {result.message}")
Failed deliveries remain in the database with their error status. You can implement retry logic if appropriate, but most applications simply log failures and move on. Temporary failures (like network timeouts) might succeed on retry, but permanent failures (like 404 Not Found) will not.
Summary
You have learned how to publish content to the Fediverse by creating activities and delivering them to follower inboxes. The publishing workflow involves creating ObjectContext records for content, wrapping them in ActivityContext records that describe what happened, and creating Notification records that trigger HTTP delivery to follower inboxes.
The toolkit handles the HTTP delivery automatically. You create the activities with proper addressing, iterate through actor.followers_inboxes to get inbox References, and create Notification records for each inbox. The send_notification task signs the requests and POSTs them to remote servers.
This architecture separates content creation from delivery mechanics. You focus on creating activities with the right structure and addressing. The toolkit handles protocol details like HTTP signatures, JSON-LD serialization, and retry logic.
Your application now fully participates in the Fediverse. It receives activities through inboxes (previous tutorial) and publishes activities through outboxes (this tutorial). Users can follow your actors, receive updates when content is published, and interact with your content through likes, shares, and replies.