Ephemeral posts that act like everything else.
Stories ride on `kind: story` with an `expires_at`. Visibility, polls, view tracking, close-friends lists, and the Instagram-style author tray are layered on top — same surface as the rest of Social.
1
Post a story
A story is a post with kind: "story" and an expires_at ISO timestamp (typically 24h out). Visibility, attributes, body — everything else is the same as in stage 3.
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{
"actor_id": "<carol>",
"kind": "story",
"body": "Sunset behind Templo de Santo Domingo 🌅",
"attributes": { "media_url": "https://images.unsplash.com/photo-sunset.jpg" },
"expires_at": "2026-05-03T19:35:22.864Z",
"visibility": "public"
}' \
https://social.productcraft.co/v1/communities/<c>/posts2
Close-friends visibility
Set visibility: "close_friends" on a story to limit it to the author plus members of their close-friends list. Manage the list with two endpoints:
Mental model: close-friends visibility tightens the audience on top of the follow graph — it never widens it. Both the home feed and the story tray only surface authors the viewer already follows, so a close-friends story reaches followers who are also on the list, and nobody else.
# Add Bob to Alice's close friends
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "owner_actor_id": "<alice>", "member_actor_id": "<bob>" }' \
https://social.productcraft.co/v1/communities/<c>/close-friends
# Remove
curl -X DELETE -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/close-friends/<alice>/<bob>
# Read Alice's close-friends list
curl -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/actors/<alice>/close-friends?limit=503
The author tray
Instagram-style horizontal tray. One row per author the requester follows (plus the requester themselves) who has at least one unexpired-or-pinned story visible to them, ordered by recency, with an unviewed flag. Single bounded page — up to 200 rows, so pagination is always null.
curl -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/actors/<bob>/story-tray{
"data": [
{
"actor_id": "76054baa-...",
"actor_external_id": "user_carol",
"actor_display_name": "Carol Diaz",
"actor_avatar_url": "https://i.pravatar.cc/300?img=23",
"latest_story_created_at": "2026-05-02T19:35:23.238Z",
"story_count": 1,
"has_unviewed": true
}
],
"pagination": null
}4
View tracking
Record a view when the requester actually opens the story. A recorded view returns 204 and is idempotent. Viewing your own story returns 422; anything the viewer isn't allowed to see — expired, removed, out-of-visibility, or the other side of a block — returns an opaque 404 rather than leaking why.
# Record a view
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "actor_id": "<bob>" }' \
https://social.productcraft.co/v1/communities/<c>/posts/<story-uuid>/views
# Author lists viewers (author-only — 403 for anyone else)
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/posts/<story-uuid>/viewers?actor_id=<carol>&limit=50"5
Polls
Polls live in post.attributes.poll. One vote per (actor, post); revoting overwrites. Voters can read and retract their own vote, authors can see a voter breakdown, and an optional closes_at is enforced server-side — voting after it returns 422 POLL_CLOSED.
# Create a story with a poll (optional closes_at is enforced)
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{
"actor_id": "<carol>",
"kind": "story",
"body": "Best pic from today?",
"attributes": {
"media_url": "https://images.unsplash.com/photo-grid.jpg",
"poll": {
"question": "Pick your favorite",
"options": ["Mercado", "Sunset", "Rooftop"],
"closes_at": "2026-05-03T19:35:22.864Z"
}
},
"expires_at": "2026-05-03T19:35:22.864Z"
}' \
https://social.productcraft.co/v1/communities/<c>/posts
# Vote (option_index is 0-based). 422 POLL_CLOSED after closes_at.
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "actor_id": "<bob>", "option_index": 1 }' \
https://social.productcraft.co/v1/communities/<c>/posts/<post-uuid>/votes
# Read your own vote (404 if you haven't voted)
curl -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/posts/<post-uuid>/votes/<bob>
# Retract your vote (idempotent 204)
curl -X DELETE -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/posts/<post-uuid>/votes/<bob>
# Aggregate results
curl -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/posts/<post-uuid>/poll-results
# Author-only voter breakdown (pass the author via actor_id; filter by ?option=)
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/posts/<post-uuid>/voters?actor_id=<carol>&option=1"6
Highlights
Pin a story to keep it past its expiry. The pinned set for an actor surfaces as their highlights. Pass ?actor_id= (the requester) when reading someone else's highlights — without it only their public pinned posts come back.
# Pin
curl -X PATCH -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "pinned": true }' \
https://social.productcraft.co/v1/communities/<c>/posts/<post-uuid>
# List Carol's highlights as Bob
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/actors/<carol>/highlights?actor_id=<bob>&limit=50"