This document explains the correct pattern for implementing ActivityPub Client-to-Server interactions, where clients post Activity objects and servers process them based on the activity type.
Clients POST Activity objects to the outbox. The server processes based on the type field in the JSON.
According to the ActivityPub C2S Specification:
Clients send an HTTP POST to the actor's outbox URL. The request body may be either:
- A single Activity object (which may include embedded other objects), or
- A single non-Activity AS2 object — in which case the server must wrap it inside a
Createactivity.
Instead of separate endpoints for different activity types, there should be one endpoint:
POST /users/{username}/outbox
Content-Type: application/activity+json
The server then determines what to do based on the type field in the JSON payload.
Note: This is the ActivityPub endpoint (required by spec). For UI convenience, you can also provide /api/* endpoints, but the ActivityPub endpoint is the standard. See API Design for endpoint organization strategy.
The server reads the type field from the JSON to determine how to process it:
{
"type": "Create",
"object": {
"type": "Note",
"content": "Hello world"
}
}{
"type": "Create",
"object": {
"type": "https://fedisports.example/ns#Run",
"distance": 5000,
"duration": "PT25M30S"
}
}{
"type": "Follow",
"object": "https://other-instance.com/users/bob"
}{
"type": "Like",
"object": "https://other-instance.com/users/bob/activities/123"
}@POST
@Path("/users/{username}/outbox")
@Consumes("application/activity+json")
@Produces("application/activity+json")
public Response postToOutbox(
@PathParam("username") String username,
JsonNode activityJson) {
// 1. Authenticate and authorize the actor
Actor actor = authenticateAndAuthorize(username);
// 2. Extract activity type from JSON
String activityType = activityJson.get("type").asText();
// 3. Process based on activity type
switch (activityType) {
case "Create":
return handleCreate(actor, activityJson);
case "Update":
return handleUpdate(actor, activityJson);
case "Delete":
return handleDelete(actor, activityJson);
case "Follow":
return handleFollow(actor, activityJson);
case "Like":
return handleLike(actor, activityJson);
case "Announce":
return handleAnnounce(actor, activityJson);
case "Undo":
return handleUndo(actor, activityJson);
default:
return Response.status(400)
.entity("Unsupported activity type: " + activityType)
.build();
}
}For Create activities, the server must also inspect the object type:
private Response handleCreate(Actor actor, JsonNode activityJson) {
JsonNode objectNode = activityJson.get("object");
if (objectNode == null) {
return Response.status(400).entity("Create activity missing object").build();
}
// Determine object type from JSON
String objectType = objectNode.get("type").asText();
// Process based on object type
if ("Note".equals(objectType)) {
return handleCreateNote(actor, activityJson, objectNode);
} else if (objectType.contains("Run")) {
return handleCreateRun(actor, activityJson, objectNode);
} else if (objectType.contains("Ride")) {
return handleCreateRide(actor, activityJson, objectNode);
} else if (objectType.contains("Swim")) {
return handleCreateSwim(actor, activityJson, objectNode);
} else if (objectType.contains("Walk")) {
return handleCreateWalk(actor, activityJson, objectNode);
} else {
return Response.status(400)
.entity("Unsupported object type: " + objectType)
.build();
}
}Client POST:
POST /users/alice/outbox
Content-Type: application/activity+json
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"object": {
"type": "Note",
"content": "Just finished a great run!"
}
}Server Processing:
- Receives JSON at outbox endpoint
- Reads
type: "Create" - Reads
object.type: "Note" - Processes as Create Note activity
- Stores activity and object
- Delivers to followers
Client POST:
POST /users/alice/outbox
Content-Type: application/activity+json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://fedisports.example/ns"
],
"type": "Create",
"object": {
"type": "https://fedisports.example/ns#Run",
"name": "Morning 5K",
"distance": 5000,
"duration": "PT25M30S",
"startTime": "2026-01-17T07:00:00Z"
}
}Server Processing:
- Receives JSON at outbox endpoint
- Reads
type: "Create" - Reads
object.type: "https://fedisports.example/ns#Run" - Processes as Create Run activity
- Stores activity and custom Run object
- Delivers to followers
Client POST:
POST /users/alice/outbox
Content-Type: application/activity+json
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Follow",
"object": "https://other-instance.com/users/bob"
}Server Processing:
- Receives JSON at outbox endpoint
- Reads
type: "Follow" - Processes as Follow activity
- Sends Follow to target actor's inbox
- Waits for Accept response
According to the ActivityPub spec, different activity types have specific side effects:
| Activity Type | Server Must Do |
|---|---|
| Create | Create the object; set attributedTo if missing; deliver to recipients |
| Update | Update the object (partial update); check permissions |
| Delete | Mark object as deleted (Tombstone); return 410 Gone |
| Follow | Add to following collection (pending until Accept) |
| Like | Add to actor's liked collection |
| Announce | Share/boost the object |
| Undo | Reverse the effects of the original activity |
| Block | Prevent interactions from blocked actor |
For Create activities, the server must handle different object types:
- Standard Types:
Note,Article,Image,Video - Custom Types:
Run,Ride,Swim,Walk(with custom vocabulary)
The server determines the object type from:
{
"type": "Create",
"object": {
"type": "..." // <-- Object type determined here
}
}- Spec Compliant: Follows ActivityPub C2S specification exactly
- Extensible: Easy to add new activity types without new endpoints
- Consistent: All activities go through the same endpoint
- Flexible: Clients can send any valid ActivityPub activity
- Standard: Works with any ActivityPub client
Don't do this for ActivityPub endpoints:
POST /users/{username}/post # For Note
POST /users/{username}/post/run # For Run
POST /users/{username}/post/ride # For Ride
Why it's wrong:
- Not ActivityPub compliant
- Requires separate endpoints for each type
- Doesn't work with standard ActivityPub clients
- Breaks federation expectations
Note: You can provide these as /api/* endpoints for UI convenience, but the ActivityPub standard endpoint (POST /users/{username}/outbox) must also exist. See API Design for details.
When implementing the outbox POST endpoint:
- Single endpoint:
POST /users/{username}/outbox - Content-Type: Accept
application/activity+json - Authentication: Verify actor owns the outbox
- Type extraction: Read
typefield from JSON - Type-based routing: Process based on activity type
- Object type handling: For Create, also check
object.type - Side effects: Implement required side effects per activity type
- Response: Return
201 CreatedwithLocationheader - Storage: Add activity to outbox collection
- Delivery: Deliver to recipients (for Create, Update, etc.)
To migrate from separate endpoints to the C2S pattern:
-
Add POST to OutboxResource (ActivityPub endpoint):
@POST @Path("/users/{username}/outbox") @Consumes("application/activity+json") public Response postToOutbox(...)
-
Extract activity type from JSON
-
Route to appropriate handler based on type
-
For Create activities, extract object type and route accordingly
-
Optionally move convenience endpoints to
/api/*:@POST @Path("/api/users/{username}/posts/run") @Consumes(MediaType.APPLICATION_JSON) public Response createRun(...)
-
Keep both: ActivityPub endpoint for federation,
/api/*endpoints for UI convenience
See: API Design for endpoint organization strategy.
Key Points:
- ✅ Clients POST Activity objects to
/users/{username}/outbox - ✅ Server processes based on
typefield in JSON - ✅ For Create activities, also check
object.type - ✅ Single endpoint handles all activity types
- ✅ Follows ActivityPub C2S specification
Pattern:
Client → POST Activity JSON → Outbox Endpoint → Extract type → Process accordingly