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 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.actor_id(again) — the author on send / edit (request body) and on delete (as the?actor_id=query parameter).caller_actor_id— the acting actor on group member add / participant PATCH; removals pass?actor_id=instead.actor_id— the reactor on DM reactions, and the renamer onPATCH /conversations/:id.
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, "muted_until": null, "pinned": false, "role": "member" },
{ "actor_id": "<bob>", "joined_at": "...", "last_read_at": null, "muted": false, "muted_until": null, "pinned": false, "role": "member" }
],
"unread_count": 0,
"created_at": "2026-05-01T12:00:00.000Z",
"updated_at": "2026-05-01T12:00:00.000Z",
"last_message_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 own CDN URLs on attachments ({ kind, url } each), or reference ProductCraft-hosted uploads by id on assets (max 20 — see the media guide). 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 '{ "actor_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 '{ "actor_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_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages/<msg>?actor_id=<alice>"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 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.
De-dupe by id when you append. Timestamps on the wire are millisecond-precision while the stored value is finer-grained, so the boundary message the token was built from usually comes back in the next since page. Dropping ids you've already rendered makes the loop correct regardless.
# 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 -w0 | tr -d '=')
curl -H "Authorization: Bearer pcft_live_..." \
"https://social.productcraft.co/v1/communities/<c>/conversations/<conv>/messages?actor_id=<alice>&since=$SINCE"Don't poll tightly if you have a backend that can receive callbacks: Social emits a dm.message.created outbound webhook on every send, carrying conversation_id, message_id, sender_id and recipient_actor_ids. Subscribe to that, then use since as the catch-up call after the webhook wakes you. Polling every few seconds per open thread is the fallback when you have no receiver.
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 '{ "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 any conversation containing an actor the viewer has restricted — groups included; ?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"Every conversation in the list carries a per-requester unread_count — messages from others after your read marker — so an inbox renders badges from the one list call, no per-thread follow-up. The single GET /conversations/:id carries it too.
7
Inbox management — pin, mute, mark-unread
The caller's participant row holds the inbox state, all set through PATCH /conversations/:id/members/:actorId (the caller is the path actor; pass caller_actor_id in the body):
- Pin —
{ "pinned": true }. Pinned threads lead the list across every cursor page. - Mute —
{ "muted": true }(indefinite) or{ "muted_until": "2026-05-02T12:00:00Z" }(timed). Adm_new_messageis suppressed while either applies; a pastmuted_untilis inert (notifications resume on their own, no cleanup call). Setting one never changes the other. - Mark-unread — no dedicated endpoint. The read marker (
POST /conversations/:id/readwithread_through) moves backwards too: set it before a message to make that thread unread again.
Need one message without paging the thread (e.g. to render a reply target)? GET /conversations/:id/messages/:messageId — participant-gated, 404 otherwise.
# pin + timed-mute in one PATCH
curl -X PATCH -H "Authorization: Bearer pcft_live_..." \
-H "Content-Type: application/json" \
-d '{"caller_actor_id":"<alice>","pinned":true,"muted_until":"2026-05-02T12:00:00Z"}' \
"https://social.productcraft.co/v1/communities/<c>/conversations/<id>/members/<alice>"Leaving a thread is DELETE /conversations/:id?actor_id=<actor> — the conversation is hard-deleted when the last participant leaves.
8
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 / self-state —
PATCH /members/:actorIdis the one participant PATCH, with field-level authz:roleis admin-only (demoting the last admin is a409);muted/muted_until/pinnedare self-only (the caller must be the path actor). Changing another actor's mute/pin is a403. - Remove —
DELETE /members/:actorId. Members may remove themselves (self-leave); removing the last admin of a non-empty group is a409. - Rename —
PATCH /conversations/:idwithactor_id(the caller — note this one is notcaller_actor_id) andname. Null/empty clears it;409on a direct conversation.
Members get dm_added_to_group and dm_role_changed notifications for the relevant transitions — see stage 8.
9
Moderation lane
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.
To take a message down, post a direct moderation action — same soft-delete as a sender-initiated one (body becomes "(deleted)", deleted_at set), with an audit row attached:
curl -X POST -H "Authorization: Bearer pcft_live_..." \
-H "content-type: application/json" \
-d '{ "target_kind": "direct_message", "target_id": "<msg>", "action": "remove" }' \
https://social.productcraft.co/v1/workspaces/<ws>/communities/<c>/moderation/actionsActor-level levers (suspension, shadow bans, blocks) live on the same controller — see the moderation guide.
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.