Pre-1.0. Not intended for public use yet. The API is subject to undocumented change, and these pages may already be wrong about it.

Skip to content

The Payload Contract ​

Introduction ​

Payload v1 describes each feed node for rendering without domain-specific knowledge.

Response Envelope ​

jsonc
{
  "payload_version": 1,
  "items": [ /* activity nodes and group nodes, newest first */ ],
  "next_cursor": "eyJ...",   // opaque string, or null at end of feed
  "sync_token": "01J3…"      // opaque string, or null
}

Empty PHP maps such as data and attributes serialize as []; populated string-keyed maps serialize as JSON objects.

Entities ​

Every role (actor, object, target, context, origin, result, instrument) is null or:

jsonc
{
  // morph alias, never a class name
  "type": "delivery",
  // string-cast
  "id": "42",
  // snapshot label, or the resolver's; null ⇒ degraded (no snapshot yet)
  "label": "Delivery #1042",
  // resolved at read time; null ⇒ not linkable
  "url": "https://…/deliveries/1042",
  // link attributes, e.g. {"target": "_blank"}
  "attributes": {},
  // hint: open as a modal
  "modal": false,
  // snapshot data
  "data": {},
  // live image slots and optional attachment
  "media": null,
  // a list of bodies, or null when there are none
  "body": null,
  // null, or what a deleted entity left behind
  "tombstone": null
}

The static Feedable::feedMedia(FeedContext): ?FeedMedia resolver supplies url, attributes, modal, and media when the feed is retrieved. It receives the snapshot, which supplies type, id, data, and label; the resolver may override label. The body list contains stored bodies followed by resolved bodies, each identifying its type with $body. See Activity Content and Feedable API.

FeedEntity also accepts content (authored text), mediaType (its encoding), and attributedTo (the author’s IRI). These snapshot keys appear only when non-null; an empty content string is preserved.

Tombstones ​

Deleted models are replaced by tombstones in existing activities. See Deleted Models for the lifecycle.

jsonc
"object": {
  // always this alias
  "type": "storyfeed.tombstone",
  // the tombstone's key, not the deleted model's
  "id": "17",
  // null, unless the model kept its label
  "label": null,
  // always null
  "url": null,
  "attributes": {},
  "modal": false,
  "data": {},
  "media": null,
  "body": null,
  "tombstone": {
    // the deleted model's morph alias
    "formerType": "order",
    // ISO 8601, or null when unknown
    "deleted": "1985-07-04T12:00:00.000000Z",
    // true when the trickle found the deletion
    "approximate": false,
    // reserved; always null
    "removedBy": null
  }
}

A role has one of these states:

StateShape
Emptynull; for the actor, this means anonymous: no actor was recorded
Degradedthe model's own type, label: null, url: null, tombstone: null
Tombstonedtype: "storyfeed.tombstone", url: null, tombstone: {…}

When approximate is true, deleted records when storyfeed:trickle found the model missing. It is not the exact deletion time. Headlines, icons, and intents use formerType, so order.place still applies to a deleted order.

Media ​

jsonc
"media": {
  // small, representational, ~32×32, 1:1: an avatar, a logo
  "icon": null,
  // a larger visual representation of a NON-image resource
  "image": null,
  // a preview of the resource: the dense-feed thumbnail
  "preview": {
    "src": "https://…/photos/88/thumb.jpg",
    "mediaType": "image/jpeg",
    // int, or null when unknown; never 0
    "width": 400,
    "height": 300,
    // optional alt text; null is preserved
    "alt": null
  },
  // the resource itself is an image; describes what `entity.url` points at
  "url": {
    // always equal to `entity.url`
    "src": "https://…/photos/88/full.jpg",
    "mediaType": "image/jpeg",
    "width": 4032,
    "height": 3024,
    "alt": "Pad thai, table 4"
  },
  "files": []
}
ValueMeaning
media: nullthe entity has no media; the common case
media: {…}all four image keys present, each an image object or null, plus files (an empty list when none)
media.url !== nullentity.url identifies an image
width, heightdimensions for reserving display space before loading; null when unknown, never 0

entity.url remains a string. If it represents an image, media.url contains the same location with mediaType, width, and height. The four media keys use Activity Streams 2.0 definitions: for a photo, url identifies the full image and preview its thumbnail. Group sample entities use the same media structure.

files is a list of resources carrying type, href, mediaType, and name from FeedResource. Each resource defaults to type Document.

Feed-Specific Resolution ​

The resolver context includes the registered feed name, allowing one snapshot to produce different URLs per feed. This name comes from the registry, not the request. For example, a shop feed may include a signed operational link that is absent from the customer feed.

Nodes do not identify their source feed, so include the feed name in payload cache keys.

Degraded Entities ​

Entities without snapshots remain in the payload with label: null, url: null, and media: null; their resolver is not called. storyfeed:trickle creates missing snapshots. If feedMedia() throws, Storyfeed reports the exception and returns url: null and media: null.

Activity Nodes ​

jsonc
{
  "kind": "activity",
  // a ULID
  "id": "01J1K2M3N4P5Q6R7S8T9V0W1X2",
  "verb": "confirm",
  "published_at": "1985-07-04T14:03:22.000000Z",
  "headline_template": ":actor confirmed :object for :target",
  // pre-rendered fallback; see below
  "headline": null,
  "glyph": "circle-check",
  // the app's own word for what the glyph means
  "glyph_intent": null,
  "actor": { /* entity */ },
  "object": { /* entity */ },
  "target": { /* entity or null */ },
  "context": { /* entity or null */ },
  "origin": { /* entity or null */ },
  "result": { /* entity or null */ },
  "instrument": { /* entity or null */ },
  "data": {},
  // the roles holding a tombstone, in role order
  "tombstoned": [],
  // one of them is a role the verb is about
  "redundant": false,
  // the verb's reading once redundant
  "missing_headline_template": null,
  // the same, pre-rendered
  "missing_headline": null
}
KeyTypeHolds
kindstringalways "activity"
idstringthe activity's stable, opaque id
verbstringthe recorded verb
published_atstringISO 8601 with microseconds
headline_templatestring or nullthe headline, with its tokens; see Headlines
headlinestring or nullthe pre-rendered fallback; see Headlines
glyphstring or nullthe icon token; see Icons
glyph_intentstring or nullthe icon's meaning; see Icons
actor, object, target, context, origin, result, instrumententity or nullthe entity in each role
datamap or nullwhat the recording call passed to data()
tombstonedlistthe roles ("object", "target", …) whose entity is a tombstone; [] when none
redundantbooleantrue when a tombstoned role is selected for redundancy checks: the object by default, none for a removal verb, or what the verb's missing() selects
missing_headline_templatestring or nullthe verb's ->missingHeadline(), when redundant is true and the verb declares one; otherwise null. headline_template keeps its value either way
missing_headlinestring or nullthe finished text when ->missingHeadline() is a closure that returns text without role tokens, as headline is for headline_template; otherwise null

A redundant activity still records what happened, but a relevant model has been deleted. Your renderer may display the original or missing headline.

Quoted words belong in an entity's Excerpt body.

Group Nodes ​

jsonc
{
  "kind": "group",
  // stable within its window
  "id": "grp_3f9a…",
  // unknown values: render as a generic group
  "axis": "actors",
  // true total members
  "count": 5,
  "verb": "place",
  // max of members; the sort key
  "published_at": "1985-07-04T14:03:22.000000Z",
  "headline_template": ":actors ordered from :target",
  "headline": null,
  "glyph": "shopping-bag",
  "glyph_intent": null,
  "actor": null,
  "object": null,
  "target": { /* the shared target entity */ },
  "context": null,
  "origin": null,
  "result": null,
  "instrument": null,
  // every role is a LIST
  "sample": {
    "actors": [ /* up to 3 entities */ ],
    "objects": [ /* up to 3 entities */ ],
    "targets": [ /* the shared target entity */ ],
    "contexts": [ /* one context entity */ ],
    "origins": [],
    "results": [],
    "instruments": []
  },
  "distinct": {
    "actors": 5, "objects": 3, "targets": 1, "contexts": 1,
    "origins": 0, "results": 0, "instruments": 0
  },
  "children": [ /* member activity nodes, newest first, possibly truncated */ ],
  "children_truncated": false,
  // roles with a tombstone among the distinct entities
  "tombstoned": [],
  // true when every member is redundant
  "redundant": false,
  // per role, how many distinct entities are tombstones
  "distinct_tombstoned": {
    "actors": 0, "objects": 0, "targets": 0, "contexts": 0,
    "origins": 0, "results": 0, "instruments": 0
  }
}
KeyTypeHolds
kindstringalways "group"
idstringgrp_ and a hash; stable within its window
axisstringthe axis that grouped the members, or "summary" on a summary row. Render an unknown value as a generic group
countintthe true number of members
verbstring or nullthe members' verb; null on a summary row spanning several verbs
published_atstringthe newest member's; the sort key
headline_template, headlinestring or nullthe group headline; both null when no sentence is true of the whole group
glyph, glyph_intentstring or nullas on an activity node; null when verb is
actor, object, target, context, origin, result, instrumententity or nullthe role's one entity, when every member shares it; see below
samplemap of listsdistinct entities per role, limited by grouping.sample_limits (default three) and the loaded members; live ones before tombstoned ones
distinctmap of intsper role, the true count of distinct entities across all members
childrenlistmember activity nodes, newest first, at most grouping.children_limit
children_truncatedbooleantrue when count is more than the children included
tombstonedlistthe roles with at least one tombstone among their distinct entities
redundantbooleantrue only when every member is redundant
distinct_tombstonedmap of intsper role, how many of the distinct entities are tombstones

A singular role contains an entity only when the grouping rule uses that role, every member shares it, its sample has one entry, and its distinct count is one. Otherwise it is null. Each plural role has a limited sample and a distinct count; empty roles use [] and 0.

Renderers may rely on the group node's structure. Which groups appear depends on server-side rules, thresholds, and periods that may change.

Summary Rows ​

A summary() group uses the group shape above with axis: "summary" and these additional fields:

KeyHolds
period"hour", "day", "week" or "month", the calendar period passed to summary(); "day" by default
phrasesper-verb summaries, ordered by when each verb first occurred, limited by grouping.summary.phrases (default three)
phrases_truncatedtrue when more per-verb phrases exist than are included

Each entry in phrases carries its own fields:

KeyHolds
verb, countthe verb and its total activity count
headline_template, headlinethe headline declared as summary.{type}.{verb} or summary.{verb}; both nullable
glyph, glyph_intentpresentation for this phrase's verb; both nullable
sample, distinctsampled entities and true distinct counts per plural role, as on the group

The summary node's headline_template and headline come from summary.* and may both be null. Nodes spanning several verbs have verb: null, glyph: null, and glyph_intent: null; their phrases retain those values. To render a summary row, display the actor followed by the joined phrases. Actors whose entire period contains the same single activity may share a summary node; use sample.actors and distinct.actors when actor is null.

When phrases_truncated is true, calculate remaining activities with count - sum(phrases[*].count). Use this total for “and N more”; it counts activities, not omitted verbs. grouping.children_limit separately limits children, with children_truncated indicating omitted members.

Presentation Fields ​

Icons ​

glyph contains the icon token declared with icon(). Storyfeed includes no icon set and returns null when no icon is defined for the type and verb.

glyph_intent contains the token declared with intent(), such as "success" or "danger". It describes the icon's meaning. Any string is accepted; Storyfeed provides no fixed vocabulary or validation. Without a declaration, it is null. See icon meanings.

Icons and intents are resolved independently in this order: type.verb, type.*, *.verb, then *.*.

Activity Streams 2.0 documents include neither token; their icon contains the entity's icon image.

Headlines ​

Render headline_template by replacing its tokens. headline contains finished text and is null when headline_template is set. For activity nodes, a closure returning role tokens sets headline_template; a closure without them sets headline. For group nodes, closures always set headline. Tests for activity closures returning tokens should check headline_template.

Both fields are null when no headline describes every group member. Renderers must handle this case; see Rendering.

See Aggregation for allowed group tokens. Singular tokens require a shared grouping role. The singular fallback may also retain a role token when the group has exactly one distinct entity for that role.

Noun substitution can change the emitted template even for the same headline definition, so cache rendered headlines per node, not per definition.

Pagination and Synchronization ​

Cursors ​

  • Store cursors and return them unchanged; do not parse them.
  • Results are ordered by published_at, newest first.
  • next_cursor: null marks the end.

Sync Tokens ​

Store the opaque sync token with the cursor and compare tokens for equality. If a later page's token differs, discard all accumulated nodes and fetch from the start. A change from null to a non-null value also requires this.

storyfeed:curate --rehash can change the token during pagination. Check it even when items is empty and next_cursor is non-null. Clients must handle changed tokens to comply with the payload contract.

Released under the MIT License.