Build a social app
10 · Messaging

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 on PATCH /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.

bash
# 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
response.json
{
  "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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
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). A dm_new_message is suppressed while either applies; a past muted_until is 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/read with read_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.

bash
# 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 /members with caller_actor_id + actor_id (+ optional role). Refused with 409 if 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/:actorId is the one participant PATCH, with field-level authz: role is admin-only (demoting the last admin is a 409); muted / muted_until / pinned are self-only (the caller must be the path actor). Changing another actor's mute/pin is a 403.
  • Remove DELETE /members/:actorId. Members may remove themselves (self-leave); removing the last admin of a non-empty group is a 409.
  • RenamePATCH /conversations/:id with actor_id (the caller — note this one is not caller_actor_id) and name. Null/empty clears it; 409 on 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:

bash
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/actions

Actor-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.