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

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, "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.

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

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

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 '{ "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.

bash
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 /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 PATCH /members/:actorId with a role. Demoting the last admin is a 409.
  • Remove DELETE /members/:actorId. Members may remove themselves (self-leave); removing the last admin of a non-empty group is a 409.
  • RenamePATCH /name (null/empty clears the name). 409 on 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.