Direct messages and group chats.
Conversations are 1-on-1 (direct) or multi-participant (group) message threads. Sending, editing, soft-deleting, emoji reactions, read markers, unread badges, an Instagram-style message-requests split, and a read-only moderation lane — all under /conversations.
Model
Who's acting? Field names
Every messaging call names the acting actor explicitly (the PAK lane has no end-user principal). The field name varies by operation — these are the names as they exist on the wire today:
actor_id— the viewer/participant: open a conversation, list conversations, list messages, mark read, mute, leave.recipient_actor_id— the other side when opening a 1-on-1.sender_id— the author on send / edit / delete message.caller_actor_id— the acting admin on group member ops (add / remove / promote / rename).src_actor_id— the reactor on DM reactions.
All of them take the social actor.id UUID from stage 2.
1
Open a conversation
Direct (default): idempotent on the pair — opening Alice ↔ Bob twice returns the same conversation, so "message this user" buttons don't need a lookup first. Group: pass kind: "group" with actor_ids (initial members, excluding the creator) and an optional name; every call creates a new group, and the creator becomes its admin. Size is capped by community.settings.max_group_conversation_size (default 50, see the settings reference). Opening against a blocked pair returns 409.
# 1-on-1 (idempotent)
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "actor_id": "<alice>", "recipient_actor_id": "<bob>" }' \
https://social.productcraft.co/v1/communities/<c>/conversations
# Group
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{
"actor_id": "<alice>",
"kind": "group",
"actor_ids": ["<bob>", "<carol>"],
"name": "Design crit"
}' \
https://social.productcraft.co/v1/communities/<c>/conversations{
"id": "<conversation-uuid>",
"community_id": "<community-uuid>",
"kind": "direct",
"name": null,
"created_by": null,
"participants": [
{ "actor_id": "<alice>", "joined_at": "...", "last_read_at": null, "muted": false, "role": "member" },
{ "actor_id": "<bob>", "joined_at": "...", "last_read_at": null, "muted": false, "role": "member" }
],
"last_message_at": "2026-05-01T12:00:00.000Z",
"created_at": "2026-05-01T12:00:00.000Z",
"updated_at": "2026-05-01T12:00:00.000Z"
}2
Send, edit, soft-delete
Bodies are 1–10 000 chars after trim. kind defaults to text; media kinds (image / video / audio) carry your CDN URLs on attachments, same bring-your-own-media model as posts. reply_to_id gives you inline threaded replies. Edits are sender-only and window-limited — 5 minutes after send, then 409. Deletes are sender-only soft-deletes: the row stays so replies don't orphan, and reads return body: "(deleted)" with deleted_at set. Sending into a conversation where a block now exists returns 409.
# Send
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "sender_id": "<alice>", "body": "Lunch at 1?" }' \
https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages
# Edit (sender-only, 5-minute window)
curl -X PATCH -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "sender_id": "<alice>", "body": "Lunch at 1:30?" }' \
https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages/<msg>
# Soft-delete (sender-only; body becomes "(deleted)")
curl -X DELETE -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "sender_id": "<alice>" }' \
https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages/<msg>3
Read messages + poll with `since`
GET /messages is cursor-paginated newest-first — the right shape for rendering a thread from the bottom and paging history upward via next_cursor. For live updates, flip to the since polling pattern: pass since=<token> where the token encodes the newest message you've already rendered, and you get only newer messages back in ascending order — append them and update your token. The token is the base64url-encoded JSON { "created_at": "<msg.created_at>", "id": "<msg.id>" } of that last-seen message, built from fields you already have on the row. Messages from actors the viewer has a block with are filtered out of every read.
# History (newest-first, page upward with next_cursor)
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages?actor_id=<alice>&limit=50"
# Poll for new messages since the last one you've seen
SINCE=$(printf '{"created_at":"%s","id":"%s"}' \
"$LAST_MSG_CREATED_AT" "$LAST_MSG_ID" | basenc --base64url | tr -d '=')
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages?actor_id=<alice>&since=$SINCE"Polling every few seconds per open thread is the intended delivery mechanism today. Social's outbound webhooks don't yet emit DM events (the event catalog covers posts, comments, reactions, and follows) — when a dm.message.created event ships, it will replace the tight polling loop and since becomes the catch-up call after a webhook wakes you.
4
Read markers + unread badges
Each participant row carries last_read_at. POST /read advances it — omit read_through to mark everything up to now, or pass a timestamp to mark partially (messages with created_at <= read_through count as read). The global badge comes from GET /conversations/unread-count?actor_id= — one { "count": n } across every conversation the actor participates in, counting only incoming (not own) messages newer than each thread's marker.
# Mark the thread read up to now (204)
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "actor_id": "<alice>" }' \
https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/read
# Navbar badge
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations/unread-count?actor_id=<alice>"
# → { "count": 7 }5
DM reactions
Same free-form type reactions as posts and comments, on individual messages. POST is idempotent on (actor, type); DELETE is idempotent and returns the updated counts either way. Each message carries a denormalised reaction_counts map, updated in lock-step. Recipients get a dm_reaction notification.
# React
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "src_actor_id": "<bob>", "type": "heart" }' \
https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages/<msg>/reactions
# Un-react (idempotent; returns the counts)
curl -X DELETE -H "Authorization: Bearer pcft_live_..." \
https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages/<msg>/reactions/<bob>/heart
# List reactors (viewer must be a participant)
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages/<msg>/reactions?actor_id=<alice>"6
Inbox vs message requests (restrict-driven)
The conversation list supports the Instagram-style "message requests" split, driven by restrict edges: ?status=inbox (default) hides conversations whose other participant the viewer has restricted; ?status=requests shows only those; ?status=all merges both. Conversations sort by most recent activity (last_message_at), cursor-paginated.
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations?actor_id=<alice>&status=inbox&limit=20"Per-thread mute lives on the caller's participant row: PATCH /conversations/:id with { "actor_id": "<alice>", "muted": true } suppresses dm_new_message notifications for that thread without leaving it. Leaving is DELETE /conversations/:id with the actor's id in the body — the conversation itself is hard-deleted when the last participant leaves.
7
Group admin operations
Groups have member and admin roles (the creator starts as admin). Admin-only, all under the conversation:
- Add a member —
POST /memberswithcaller_actor_id+actor_id(+ optionalrole). Refused with409if the new member has a block with any existing participant, is already a member, or the group is full. - Promote / demote —
PATCH /members/:actorIdwith arole. Demoting the last admin is a409. - Remove —
DELETE /members/:actorId. Members may remove themselves (self-leave); removing the last admin of a non-empty group is a409. - Rename —
PATCH /name(null/empty clears the name).409on a direct conversation.
Members get dm_added_to_group and dm_role_changed notifications for the relevant transitions — see stage 8.
8
Moderation lane (read-only)
Workspace moderators can review DM content without being participants — the admin lane exposes GET /v1/workspaces/:ws/communities/:c/moderation/conversations (every conversation, most-recent-activity first) and .../conversations/:id/messages (every message, including soft-deleted rows' metadata). Requires the social.audit.read permission; auth is the admin lane's usual PAK bearer / PlatformUser bearer / session cookie. The lane is read-only today — moderator take-downs of individual messages aren't shipped yet; actor-level tools (suspension, shadow bans, blocks) are the enforcement levers.
Wrap-up
The full loop
Open → send → poll with since → mark read → badge from unread-count: that's a complete DM product. With messaging done you've now walked every primitive the tutorial covers — head back to the index or dig into the single-feature guides.