Activity · First request
Publish a post and read a timeline
This path creates one post for the signed actor, then reads the same actor’s materialized home timeline. Run it from a trusted host server that can access the gateway secret.
1. Serialize and sign the post
const path = "/api/v1/posts";
const timestamp = Math.floor(Date.now() / 1000).toString();
const body = JSON.stringify({
idempotency_key: "match:match_1842:published",
verb: "matched",
object_ref: { type: "match", id: "match_1842" },
body: "Won the summer qualifier",
metadata: { season: "2026-summer" },
render_refs: {},
visibility: "followers"
});
const signature = signActivityRequest({
secret: process.env.ACTIVITY_GATEWAY_SECRET,
timestamp,
method: "POST",
path,
tenantId,
actorId,
body
}); verb is required. Set a stable idempotency_key for each logical publish. object_ref, metadata, and render_refs default to empty objects. visibility accepts public, followers, or private.
2. Publish the post
const response = await fetch(`${activityUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-tenant-id": tenantId,
"x-actor-id": actorId,
"x-feedaas-timestamp": timestamp,
"x-feedaas-signature": signature
},
body
});
if (!response.ok) throw new Error(await response.text());
const { post } = await response.json(); A successful write returns 201 Created. Activity takes actor_id from the signed header, creates the source post, materializes the author’s self timeline entry, and schedules follower fan-out when the visibility permits it.
3. Read the home timeline
Sign the read separately. Its method, path, query string, body hash, and timestamp differ from the publish request.
const path = "/api/v1/timeline/home";
const query = "limit=20";
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = signActivityRequest({
secret: process.env.ACTIVITY_GATEWAY_SECRET,
timestamp,
method: "GET",
path,
query,
tenantId,
actorId,
body: ""
});
const response = await fetch(`${activityUrl}${path}?${query}`, {
headers: {
"x-tenant-id": tenantId,
"x-actor-id": actorId,
"x-feedaas-timestamp": timestamp,
"x-feedaas-signature": signature
}
});
if (!response.ok) throw new Error(await response.text());
const timeline = await response.json(); The response contains materialized entries and an opaque next cursor:
{
"data": [
{
"id": "timeline-entry-uuid",
"viewer_id": "player_42",
"reason": "self",
"ranked_at": "2026-07-14T04:10:00Z",
"item": {
"post": {
"id": "post-uuid",
"actor_id": "player_42",
"verb": "matched",
"visibility": "followers"
},
"stats": {
"like_count": 0,
"comment_count": 0,
"reply_count": 0,
"repost_count": 0,
"share_count": 0
},
"viewer": {
"has_liked": false,
"follows_author": true,
"can_delete": true
}
}
}
],
"page": { "next_cursor": null }
} The actual post and viewer objects contain the complete fields defined by the OpenAPI contract. The example is shortened to show the response layers.
What is synchronous
The author’s self timeline entry is created with the post. Follower fan-out runs asynchronously and is designed to converge under retries. A successful post response does not mean every follower timeline has already been updated.
Do not use PostStats as the canonical record for engagements. Those counters are also derived asynchronously.
Retry boundary
Reuse the same idempotency_key when retrying one logical publish. Within the same tenant and actor, Activity returns the original post without creating another timeline entry, fan-out job, or outbox event. Use a new key for a new publish.
Timeline reads are safe to retry with a newly signed request. Keep page.next_cursor opaque and include its exact encoded value in both the request URL and signed query string.