Understanding ActivityPub Part 1: Protocol Fundamentals
This article is part of the series Understanding ActivityPub, which takes a look at the ActivityPub protocol through the lens of real-world examples. The protocol exchanges are taken from ActivityPub.Academy, a modified Mastodon instance that shows ActivityPub messages in real time (see the announcement post).
The following articles are available.
Part 1: Protocol Fundamentals (this article)
In this blog post, I’m using ActivityPub.Academy (see the announcement post) to explore the ActivityPub protocol. We’ll see how different instances communicate that one user wants to follow another and how a social graph is built; how messages are distributed to followers on different instances; and lastly, what happens on the protocol level when an account is moved to a different one.
The blog post has two purposes. First, it can hopefully be read as a stand-alone post if you are interested in ActivityPub, but you don’t want to read the entire ActivityPub Spec. And second, it serves as an introduction on how to use ActivityPub.Academy to explore the protocol. It is an invitation to do your own experiments and explorations and learn about the protocol in this way. And once you have some practical experience with the protocol, trying to read the Spec is probably not as daunting anymore.
Actors and Inboxes
Before looking at our first activities, we need to have a quick look at actors and inboxes.
Full mentions and actor IDs
If you have used Mastodon, you are probably familiar with full mentions. For my main account, the full mention is different definition, but it is so general that it is almost useless.)
, where is the instance, and is the username on that instance. In the UI, that’s usually all you’ll ever need to deal with. But under the hood, the ActivityPub protocol doesn’t use full mentions at all. Instead, it uses actor IDs. (I think of actors as user accounts. The Spec has aThe actor ID for my main account is https://mastodon.social/GET
request against the WebFinger endpoint of the instance, passing the full mention as a query parameter. For example, to get the actor ID of my account, we can query the URL https://mastodon.social/
{
"subject":"acct:crepels@mastodon.social",
"aliases":[
"https://mastodon.social/@crepels",
"https://mastodon.social/users/crepels"
],
"links":[
{
"rel":"http://webfinger.net/rel/profile-page",
"type":"text/html",
"href":"https://mastodon.social/@crepels"
},
{
"rel":"self",
"type":"application/activity+json",
"href":"https://mastodon.social/users/crepels"
},
{
"rel":"http://ostatus.org/schema/1.0/subscribe",
"template":"https://mastodon.social/authorize_interaction?uri={uri}"
}
]
}
Under the type application/
To see a case where the full mention and the actor ID look completely unrelated, query the WebFinger protocol for https://jambor.dev/
{
"subject":"acct:seb@jambor.dev",
"aliases":[
"https://mastodon.social/@crepels",
"https://mastodon.social/users/crepels"
],
"links":[
{
"rel":"http://webfinger.net/rel/profile-page",
"type":"text/html",
"href":"https://mastodon.social/@crepels"
},
{
"rel":"self",
"type":"application/activity+json",
"href":"https://mastodon.social/users/crepels"
},
{
"rel":"http://ostatus.org/schema/1.0/subscribe",
"template":"https://mastodon.social/authorize_interaction?uri={uri}"
}
]
}
So https://mastodon.social/
Hint: On ActivityPub.Academy, you can type the full mention into the ActivityPub Explorer and it will be automatically translated into the corresponding WebFinger URL.
The inbox of an actor
The actor ID is a valid URI, and we can query it with a GET
request. The response will differ depending on the Accept
header that we specify. If we set it to text/html
(or don’t specify any header at all), it will show my profile page. You can test it out by opening https://mastodon.social/users/crepels in your browser. But if we specify the header value application/application/activity+json
, it will return the ActivityStreams representation. (The first header value is the one that the spec says that all clients must use. Mastodon breaks with this rule and uses the second value instead. I guess for this reason, the spec says that every ActivityPub server should also support this header). For my actor ID it returns
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"toot": "http://joinmastodon.org/ns#",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
},
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"discoverable": "toot:discoverable",
"Device": "toot:Device",
"Ed25519Signature": "toot:Ed25519Signature",
"Ed25519Key": "toot:Ed25519Key",
"Curve25519Key": "toot:Curve25519Key",
"EncryptedMessage": "toot:EncryptedMessage",
"publicKeyBase64": "toot:publicKeyBase64",
"deviceId": "toot:deviceId",
"claim": {
"@type": "@id",
"@id": "toot:claim"
},
"fingerprintKey": {
"@type": "@id",
"@id": "toot:fingerprintKey"
},
"identityKey": {
"@type": "@id",
"@id": "toot:identityKey"
},
"devices": {
"@type": "@id",
"@id": "toot:devices"
},
"messageFranking": "toot:messageFranking",
"messageType": "toot:messageType",
"cipherText": "toot:cipherText",
"suspended": "toot:suspended",
"focalPoint": {
"@container": "@list",
"@id": "toot:focalPoint"
}
}
],
"id": "https://mastodon.social/users/crepels",
"type": "Person",
"following": "https://mastodon.social/users/crepels/following",
"followers": "https://mastodon.social/users/crepels/followers",
"inbox": "https://mastodon.social/users/crepels/inbox",
"outbox": "https://mastodon.social/users/crepels/outbox",
"featured": "https://mastodon.social/users/crepels/collections/featured",
"featuredTags": "https://mastodon.social/users/crepels/collections/tags",
"preferredUsername": "crepels",
"name": "Sebastian Jambor",
"summary": "<p>I created a systemd playground to help people learn systemd.</p>",
"url": "https://mastodon.social/@crepels",
"manuallyApprovesFollowers": false,
"discoverable": true,
"published": "2022-05-01T00:00:00Z",
"devices": "https://mastodon.social/users/crepels/collections/devices",
"publicKey": {
"id": "https://mastodon.social/users/crepels#main-key",
"owner": "https://mastodon.social/users/crepels",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzCg7ogX8utFGjeOs7EeX\nKLUixqzlyrCFobv+Pcqhmw8WrMYnm3jrbUWkjcOUHaepAFAgauns+Tr5XwmAsNkx\nYLqyVipartwf8/tdiHZAhHgOdvtfd3dXHDpZKyeQfOtdt2AxftMihg9wmCyupAJC\naLNbyx4S9Q+aDVkaaHiFvhTNWHNR4rf3OKvQixiopry0G9oCCkUxWs+z4RT8Rx+R\nOLteufRh6WOn7YgqeMcJ4hZ/DcQZD3JtdJfEwUDftEW3xkoI/T1IA2QYAr3m4xHJ\nhv3TONr6VD/Bx5RPUsBDvXOlN5hbadClOQURybuyUBe1D+Md6SYHSQ4Q/x0y6L9+\nhQIDAQAB\n-----END PUBLIC KEY-----\n"
},
"tag": [],
"attachment": [
{
"type": "PropertyValue",
"name": "Blog",
"value": "<a href=\"https://seb.jambor.dev\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span><span class=\"\">seb.jambor.dev</span><span class=\"invisible\"></span></a>"
},
{
"type": "PropertyValue",
"name": "systemd by example",
"value": "<a href=\"https://systemd-by-example.com/\" target=\"_blank\" rel=\"nofollow noopener noreferrer me\"><span class=\"invisible\">https://</span><span class=\"\">systemd-by-example.com/</span><span class=\"invisible\"></span></a>"
}
],
"endpoints": {
"sharedInbox": "https://mastodon.social/inbox"
},
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://files.mastodon.social/accounts/avatars/108/227/485/389/961/502/original/1213d525278ae01d.png"
}
}
Hint: If you search for the actor ID (or any other ActivityPub URI) on the ActivityPub Explorer, the Accept
header will be set automatically to application/ld+json; profile="https://www.w3.org/ns/activitystreams"
.
If you scroll past the @context
field you’ll see useful information about my account. (The @context
field turns the JSON response into JSON-LD. I won’t go into JSON-LD in this blog post and simply treat the responses as ordinary JSON.) The most important information for us are the inboxes. There are two of them. First there is the actors personal inbox (the inbox
field, which is https://mastodon.social/endpoints.sharedInbox
field, which is https://mastodon.social/
Inboxes are central to ActivityPub. When different ActivityPub servers talk to each other, they send Activities to inboxes, as we will see in a bit.
Another thing that we will come back to later is the publicKey
field, which contains the public key for my actor. The private counterpart is stored on .
This is all information we need for now, and equipped with this we can look at our first activities.
Building a social graph
The ActivityPub protocol comes into play whenever you use your Mastodon account to interact with accounts on a different instance, for example, if you follow another account, post a direct message to someone, or send a post to all your followers. The instances communicate those actions by sending Activities to one another. On ActivityPub.Academy we can see those activities in real time in the Activity Log. We will create an account @alice
The first activities we are taking a look at are generated when we follow other people. We will build a small social graph of four actors spread across three different instances. This will give us a lot of activities to investigate, and it will also be the basis for later examples.
The graph we want to create looks like this.
This means that @alice
I encourage you to follow along on ActivityPub.Academy. You don’t have to create a social graph with multiple people, but at least follow for example your main Mastodon account.
Let’s start from @alice’s account and follow . Clicking on the “Follow” button will trigger an exchange of Activities between activitypub.academy and . On ActivityPub.Academy, we can see this exchange in the Activity Log, which looks like
What this means is that @alice sent an activity of type Follow
to ’s inbox on , and immediately answers with an Accept
activity, sent to @alice’s inbox. The actual activities are JSON objects; you can see them by clicking on show source
.
I think this level of abstraction is most useful when trying to understand ActivityPub. We see the activities at a glance, and we see that it is a conversation between different Mastodon instances. We will use this level of abstraction throughout this blog post, and it’s exactly what you will see on ActivityPub.Academy. But I think it’s also important to understand what’s behind this abstraction, so let’s have a quick look at the technical details (a more complete look can be found in Eugen Rochko’s blog).
When @alice clicks the “Follow” button to follow , activitypub.academy sends a POST
request to ’s inbox endpoint https://techhub.social/
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://activitypub.academy/16606771-befe-483b-9e2c-0b8b85062373",
"type": "Follow",
"actor": "https://activitypub.academy/users/alice",
"object": "https://techhub.social/users/berta"
}
This JSON object represents a Follow
activity, and signifies that @alice (specified in the actor
field) wants to follow (specified in the object
field). Since ’s account is configured to accept all follow requests, immediately answers with a POST
request to @alice’s inbox endpoint https://activitypub.academy/
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://techhub.social/users/berta#accepts/follows/946055",
"type": "Accept",
"actor": "https://techhub.social/users/berta",
"object": {
"id": "https://activitypub.academy/16606771-befe-483b-9e2c-0b8b85062373",
"type": "Follow",
"actor": "https://activitypub.academy/users/alice",
"object": "https://techhub.social/users/berta"
}
}
This is again an activity, this time of type Accept
, signifying that (the actor
field) accepts the Follow
activity (specified in the object
) field.
The visualization above contains this information, but in a condensed form. The endpoint that the activity was sent to is shown in the Sent to
field. The activity itself is reduced to its type and the object (since those are usually the most interesting properties). The object can be itself an activity (like in the Accept
case above), resulting in a kind of recursive representation.
The last part from the visualization that we haven’t explained yet is the From
field. You might guess that it comes from the actor
field of the activity, but that’s not entirely correct. To explain where it actually comes from, we have to dig a little deeper.
Different Mastodon instances communicate with each other exclusively via ActivityPub. With Activities, an instance indicates that a user wants to follow another user (as we just saw), instances send messages from one user to users on other instances, and much more. Essentially, through Activities one instance tells another instance what a certain user did or intends to do. We can see above that the Activity necessary to follow another user is a simple JSON object. So what prevents me from POST
ing a JSON object to , pretending that some random user wants to follow Eugen Rochko? Or worse, pretending to be Eugen and blocking all of his followers? The answer to this is that Mastodon requires all requests to be signed to verify the author of an activity. In order to do this, every actor has a public/private key pair that we saw above. When activitypub.academy sends the Follow
activity to ’s inbox indicating that @alice wants to follow her, it signs the request using @alice’s private key and attaches a Signature
header to the request that looks something like
Signature: keyId="https://activitypub.academy/users/alice#main-key",signature="...",...
The @alice’s public key. (Notice that the keyId
in the Signature
header contains the actor ID, so if the instance has never seen @alice before, it can use the actor ID to fetch the public key.) If the signature is correct, the instance can be sure that the activity was created by @alice and processes it further; otherwise it throws an error.
So now we have the final part of the visualization. The From
field in the visualization contains the actor from this Signature
header. It is usually the same as the actor
field of the activity, but there are some cases where they differ. (I’m planning to go into this more in a future blog post.)
Let’s finish this section by adding the rest of the links from the graph above, resulting in the following Activity Log.
We now have the social graph that we wanted to build and we will continue with some other ActivityPub features. But there’s a lot more to explore when it comes to the social graph. If you are following along on ActivityPub.Academy, there are a few things you could try out, like, what happens when you unfollow another actor? What happens when your account is configured to manually approve followers and you reject a follow request? What happens when you block a follower?
ActivityPub data
So far we have looked at the ActivityPub protocol – the exchange of activities. There is another important component to ActivityPub, which I like to call ActivityPub data. We have already seen one example of ActivityPub data: the actor. The actor ID is a well-defined URL, and if we query it with a GET
request and the Accept
header set to application/ld+json; profile=GET
this, again with the special Accept
header, it returns
{
"@context":"https://www.w3.org/ns/activitystreams",
"id":"https://mastodon.social/users/crepels/followers",
"type":"OrderedCollection",
"totalItems":24,
"first":"https://mastodon.social/users/crepels/followers?page=1"
}
The first
entry points to the first page of the collection, which is again ActivityPub data. GET
ting this one shows
{
"@context":"https://www.w3.org/ns/activitystreams",
"id":"https://mastodon.social/users/crepels/followers?page=1",
"type":"OrderedCollectionPage",
"totalItems":24,
"next":"https://mastodon.social/users/crepels/followers?page=2",
"partOf":"https://mastodon.social/users/crepels/followers",
"orderedItems":[
"https://hachyderm.io/users/deomorxsy",
"https://hachyderm.io/users/olepbr",
"https://hachyderm.io/users/giulianopz",
"https://mastodon.social/users/JamborJan",
"https://mstdn.plus/users/jmuranga",
"https://sfba.social/users/sesh",
"https://fosstodon.org/users/Skel_0",
"https://social.yakshed.org/users/bascht",
"https://mastodon.social/users/tfe",
"https://mastodon.social/users/mareyher",
"https://bewegung.social/users/a_watch",
"https://fosstodon.org/users/gait"
]
}
All of the URLs again point to ActivityPub data that can be explored further.
On ActivityPub.Academy, you can open the URLs in the ActivityPub Explorer. The shown JSON object is interactive, all URLs are clickable and will fetch the corresponding ActivityPub data. This allows for convenient navigation of the ActivityPub data.
Often, the exchange of Activities triggers changes in the ActivityPub data. For example, after @alice follows , opening https://techhub.social/
{
"@context":"https://www.w3.org/ns/activitystreams",
"id":"https://techhub.social/users/berta/followers?page=1",
"type":"OrderedCollectionPage",
"totalItems":1,
"partOf":"https://techhub.social/users/berta/followers",
"orderedItems":[
"https://activitypub.academy/users/alice"
]
}
So @alice was added to the list of ’s followers. Similarly, we would see in @alice’s following
collection.
Sending messages
The main function of Mastodon is to send messages. We’ll explore how this works on the protocol level in this section, looking at different types of messages.
There are different visibility options for messages. In the broadest visibility, your messages appear in all of your follower’s feeds. Furthermore, the message is publicly available: if you send a link of the message to someone, they should be able to open the link and see the message. On the other end of the spectrum are direct messages, which should only be visible by a single recipient. In ActivityPub, all of those messages are created using the same type of Activities. The only difference is that the Activities have to
and cc
fields (similar to email) which specify who should see those messages.
Public messages
In Mastodon, you can specify who should be allowed to see your messages by setting the privacy level of a post. By default, a post is visible for everyone, and it is delivered to all of your followers. So let’s start by creating a public “Hello World” post with @alice’s account. In the Activity Log, we will see the following.
Several interesting things are happening. First, we see that messages are created using the Create
activity. Like the activities that we saw above, the Create
activity has an object
field, which in our case is an object of type Note
, containing the message in HTML format.
There are two Create
activities that are sent out by @alice, one going to the instance, and the other to the instance. This makes sense, since @alice has followers on those two instances, so they must be notified of the new post. But the post is not sent to the followers’ inboxes directly. Instead, it is sent to the shared inboxes of the instances.
Remember that @alice has three followers: one on and two on . Since @alice wants her post to be delivered to all of her followers, we could assume that this results in three Create
activities being sent, one to each inbox of all followers. But this could become very inefficient, especially when @alice has a lot of followers. To combat this, the ActivityPub Spec allows the definition of a shared inbox (providing one is optional, but Mastodon always does it). Instead of sending the Create
activity to all follower’s inboxes on an instance, we can simply send it to the shared inbox and rely on the instance to deliver it to our followers internally.
That’s what’s happening above. @alice has two followers on , and , but only one activity is sent to this instance. The question is now, how does know which actors to show this message to? That’s where the to
and cc
fields of the activity come into play. If we take a look at the source of the activity, we’ll see the five fields that we saw for the activities above (@context
, id
, type
, actor
, and object
), but also some additional fields, for instance to
and cc
. The to
field contains the special collection https://www.w3.org/cc
field contains https://activitypub.academy/
Unlisted messages
So far, we looked at public posts. What happens with the other privacy options?
Here is the result of an unlisted post.
If you look at the source, you’ll see that this time, the roles of the two address fields are reversed. The to
field contains the followers collection, and the cc
field contains the public collection. This means that the message is still sent to the Home timeline of all of @alice’s followers, and the message is still public, so everyone can see it (for example, by opening the ID of the note in a browser), but it no longer shows up in the Federated timeline.
The third privacy option is Followers only, where the to
field still contains the followers collection, but the cc
field is empty. As the name of the option suggests, the post is visible to followers only, it’s not publicly accessible. (I’ll omit the log here).
Direct messages
The strictest privacy level is the direct message, which is sent to mentioned people only. Here is an example, where we sent a direct message to
.We see that it is only sent to ActivityPub Spec, which says that the shared inbox is to be used for messages to followers and for public posts only. I guess that this is done for simplicity.
. But interestingly, it is still sent to the shared inbox, even though the only recipient is . This seems to be in violation of theBoosting messages
There are a lot of things we could experiment with when it comes to messages, but we will finish this section with boosting a message.
Let’s see what happens when @alice.
boosts the Hello World message fromBoosting happens via the Announce
activity. This activity also has to
and cc
fields; it is sent to all followers of , and additionally to @alice as the author of the original post. Note that the object
field is just a reference to the original post. In our case, the activitypub.academy instance owns the post so it can show it to all of ’s followers on this instance (which is only @alice); but it can also happen that the instance doesn’t know about the post yet, in which case it has to fetch it to show it to its users. This fetching is not part of the ActivityPub protocol, meaning that it doesn’t happen via activities. But the post can be fetched from the ActivityPub data (try opening the URI in the ActivityPub Explorer).
I want to stop this section here. But again, if you are following along on the academy, there are a lot of things you could try out. For example, what happens when you add a content warning to a post? When you edit a post? When you delete a post? When you like a post? What happens when you reply to a post, and how it is reflected in the ActivityPub data? How are polls implemented in ActivityPub?
Moving accounts
The last topic that I want to investigate in this blog post is moving accounts. One of the features of Mastodon which distinguishes it from other social networks is that if you don’t like your instance, you can move to a different instance without losing your followers. But how does that work in practice? How do your followers know that you are now using a different account? Let’s try to find out.
We will move @alice
On ActivityPub.Academy, accounts are auto-generated and tied to your active session. To create more than one user, you can open the academy in an incognito window or in another browser.
Note that the source and the destination account are on the same instance. We do this so that we can see the activity log of both actors simultaneously. The same type of activities will be sent when the actors are on different instances.
To distinguish the two activity logs, we will display the log of @alice in light mode as before, and the log of @alastair in dark mode.
Before being allowed to move @alice’s account to @alastair, we have to prepare @alastair’s account by creating an account alias. @alastair has to say that he is also known as @alice. (This prevents anyone from moving their account to an account that doesn’t belong to them.) The alias can be created in Mastodon’s Preferences section. (The preferences section is not part of the Single Page App and will result in a page navigation; this will clear the Activity Log. If you are following along, make sure to open the preferences in a separate tab.) When the alias is created, we see the following activity in the log.
An Update
activity is sent to all instances that have followers of @alastair; in our case, that’s just . The object of the activity is a Person
object, which contains the new JSON-LD presentation of the actor. You can see that there is now a alsoKnownAs
entry which contains @alice’s actor ID.
Now we can switch to @alice’s account and initiate the account move, again in the Preferences section. In @alice’s log we see the following.
As with @alastair’s account, a Person
update is sent to all instances that have followers of @alice’s. The new Person
has a movedTo
entry which points to @alastair’s actor. A separate Move
activity is sent to the instances, indicating that @alice moved to @alastair. This is followed by two Undo
activities from and , reversing their earlier Follow
activities. Curiously, there is no Undo
activity sent by , and checking ’s following
collection shows that he is indeed still following @alice. It is not entirely clear to me whether this is a bug or intentional (you could argue that is already a follower of @alastair, so he doesn’t need to be moved), but it definitely was surprising to me.
On @alastair’s Activity Log we get
Both @alastair.
and sent follow requests which were immediately accepted. didn’t send a follow request, since he was already followingSo that’s it. A Move
activity is sent to all instances with followers. Those instances are then responsible for unfollowing @alice and following @alastair for all actors that were previously following @alice.
Conclusion
In this blog post, I showed a few examples of how to use the Activity Log and the Activity Explorer to understand ActivityPub. Of course, there is a lot more to explore, and if you are interested in understanding ActivityPub, I would urge you to do your own experiments. There is no better way to really understand a topic than to actually play around with it.
If you do use the academy, I would love to hear your feedback. Does it help you to understand things that you didn’t before? Is there anything that you think could be improved? Let me know on @crepels@mastodon.social.
—Written by Sebastian Jambor. Follow me on Mastodon @crepels@mastodon.social for updates on new blog posts.