Verified against ActivityPub REC §6 and Editor's Draft (Oct 2026), FEP-d8c2, Pleroma and Akkoma routers on 2026-10-15.
Client-to-Server (C2S)
The Client-to-Server protocol defines how client applications interact with ActivityPub servers. While most Fediverse software uses custom APIs, understanding C2S helps you build spec-compliant implementations.
Overview
┌─────────────────┐ ┌─────────────────┐
│ │ POST /outbox │ │
│ Client │───────────────────►│ Server │
│ (Mobile App) │ │ (Your Server) │
│ │◄───────────────────│ │
│ │ GET /inbox │ │
└─────────────────┘ └─────────────────┘
Authentication
C2S requires authentication, but the spec leaves the mechanism unspecified: it only lists optional OAuth endpoints (oauthAuthorizationEndpoint, oauthTokenEndpoint) on the actor's endpoints (AP §B.1). Current work on a common profile is FEP-d8c2 (OAuth 2.0 profile for the ActivityPub API, DRAFT). A bearer token is typical:
POST /users/alice/outbox HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
Content-Type: application/activity+json
Posting Activities
Submit to Outbox
Clients discover the outbox from the actor document and POST to it; the request MUST be authenticated as the outbox owner, and the body MUST be a single Activity or a single non-Activity object (AP §6):
POST /users/alice/outbox HTTP/1.1
Host: example.com
Authorization: Bearer <token>
Content-Type: application/activity+json
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"object": {
"type": "Note",
"content": "Hello, world!"
}
}
Server Processing
The server (AP §6):
- Validates the activity
- Ignores any client-supplied activity
idand assigns its own (MUST) - For a
Create, assigns anidto the new object - Sets the
actorproperty - Adds the activity to the outbox collection (MUST)
- Removes
bto/bccbefore delivery (MUST) and delivers to recipients (S2S)
Response
HTTP/1.1 201 Created
Location: https://example.com/activities/123
The server MUST answer 201 Created and, unless the activity is transient, MUST put the new id in Location.
Activity Handling
Create
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"object": {
"type": "Note",
"content": "My new post",
"to": ["https://www.w3.org/ns/activitystreams#Public"]
}
}
Server adds:
idto activity and objectactorpropertyattributedToon the object — the actor SHOULD be copied there (AP §6.2)- Recipients: the server SHOULD copy addressing between the
Createand its object so they match - A
publishedtimestamp (not required by AP, but expected by most software)
Like
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Like",
"object": "https://other.example/notes/456"
}
Follow
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"object": "https://other.example/users/bob"
}
Update
In C2S, Update is a partial update: each top-level key supplied replaces the stored value (AP §6.3.1). The current Editor's Draft no longer describes using null to remove a field (ED). When the server federates the change, it sends the complete object.
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Update",
"object": {
"id": "https://example.com/notes/123",
"type": "Note",
"content": "Updated content"
}
}
Delete
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Delete",
"object": "https://example.com/notes/123"
}
Undo
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Undo",
"object": "https://example.com/activities/like-456"
}
Reading Activities
Get Inbox
GET /users/alice/inbox HTTP/1.1
Host: example.com
Authorization: Bearer <token>
Accept: application/activity+json
Response:
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "OrderedCollection",
"id": "https://example.com/users/alice/inbox",
"totalItems": 42,
"first": "https://example.com/users/alice/inbox?page=true"
}
Get Outbox
GET /users/alice/outbox HTTP/1.1
Host: example.com
Accept: application/activity+json
Direct Object Posting
For convenience, clients can POST objects directly:
POST /users/alice/outbox HTTP/1.1
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Note",
"content": "Hello!"
}
The server MUST wrap it in a Create, and copy the object's to, bto, cc, bcc and audience onto that activity (AP §6.2.1).
Addressing
Public Post
{
"type": "Note",
"content": "Hello everyone!",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/users/alice/followers"]
}
Followers Only
{
"type": "Note",
"content": "Just for my followers",
"to": ["https://example.com/users/alice/followers"]
}
Direct Message
{
"type": "Note",
"content": "Private message",
"to": ["https://other.example/users/bob"]
}
Uploaded Media
Upload Endpoint
The Recommendation leaves media upload out of scope (AP §6.12, non-normative) and points to the SocialCG's ActivityPub Media Upload report, which describes an uploadMedia endpoint; Pleroma implements one at /api/ap/upload_media. In practice each server has its own upload API; the example below is illustrative (/api/v1/media is Mastodon's client API, not ActivityPub):
POST /api/v1/media HTTP/1.1
Host: example.com
Authorization: Bearer <token>
Content-Type: multipart/form-data
[binary image data]
Response:
{
"id": "123",
"type": "Document",
"url": "https://example.com/media/123.jpg",
"mediaType": "image/jpeg"
}
Attach to Post
{
"type": "Note",
"content": "Check out this photo!",
"attachment": [
{
"type": "Document",
"url": "https://example.com/media/123.jpg",
"mediaType": "image/jpeg"
}
]
}
Implementation Example
Express.js Outbox Handler
app.post('/users/:username/outbox',
authenticate,
async (req, res) => {
const username = req.params.username;
const actor = `https://example.com/users/${username}`;
// Verify authenticated user matches
if (req.user.actor !== actor) {
return res.status(403).json({ error: 'Forbidden' });
}
let activity = req.body;
// Wrap bare objects in Create, copying addressing (AP §6.2.1)
if (!isActivityType(activity.type)) {
const object = activity;
activity = { '@context': 'https://www.w3.org/ns/activitystreams', type: 'Create', object };
for (const field of ['to', 'bto', 'cc', 'bcc', 'audience']) {
if (object[field]) activity[field] = object[field];
}
}
// Servers MUST ignore a client-supplied activity id (AP §6)
activity.id = `https://example.com/activities/${crypto.randomUUID()}`;
activity.actor = actor;
activity.published = new Date().toISOString();
if (activity.type === 'Create' && activity.object && typeof activity.object === 'object') {
// A new object: mint its id too, so clients can't claim arbitrary ids.
// (For Update/Delete the object id refers to an existing object and
// must be checked for ownership instead.)
activity.object.id = `https://example.com/objects/${crypto.randomUUID()}`;
activity.object.attributedTo = actor;
}
// Store activity
await db.activities.insert(activity);
// Deliver to recipients (async); strip bto/bcc first (AP §6)
deliverActivity(activity);
res.status(201)
.location(activity.id)
.json(activity);
}
);
C2S vs Custom APIs
| Aspect | C2S | Custom API (Mastodon) |
|---|---|---|
| Standardized | Yes | No |
| Tooling | Limited | Extensive |
| Features | Basic | Platform-specific |
| Adoption | Low | High |
When to Use C2S
- Building a generic ActivityPub client
- Interoperability is priority
- Following spec strictly
When to Use Custom API
- Platform-specific features
- Better tooling available
- Faster development
Implementations Supporting C2S
| Platform | C2S Support |
|---|---|
| Pleroma | Yes — POST outbox, GET inbox, media upload (Pleroma source, lib/pleroma/web/router.ex) |
| Akkoma | Reading the inbox only; no outbox POST (router.ex) |
| Mastodon | No (uses its own client API) |
| Pixelfed | No |
| Lemmy | No |
Next Steps
- Server-to-Server - Federation protocol
- Delivery - How activities are sent
- Building an Actor - Implementation guide