Direct Messages

Direct messages (DMs) are private, one-to-one conversations between two users. Unlike Messages, which are public or feed-scoped posts, a DM is only ever visible to its two participants.

DMs are free for everyone (no subscription required), including image attachments. Every DM endpoint accepts a session cookie or a Bearer token, so the full surface works from native and CLI clients.

Who can message whom

A DM can only be sent between two users who mutually follow each other (both follow requests approved), and neither may have blocked the other. Sending fails otherwise:

ConditionStatuserror code
Recipient is yourself400self_message
Recipient does not exist404recipient_not_found
Either user blocks the other403blocked
You are not mutual followers403not_mutual
Body missing / empty / too long400invalid_body
Attachments invalid400invalid_images

Use GET /api/dm/recipients to fetch exactly the set of users you are currently allowed to message.

Endpoint table

MethodPathAuthDescription
GET/api/dmSession or BearerList one of your DM folders. Query: folder (inbox | sent | deleted, default inbox), cursor, take.
POST/api/dmSession or BearerSend a direct message. Body: { recipientId, body, imageUrls? }. Returns 201.
GET/api/dm/:idSession or BearerFetch a single message you participate in. 404 if you are not a participant.
POST/api/dm/:id/readSession or BearerMark a received message read (recipient-scoped). Returns { updated }.
POST/api/dm/:id/trashSession or BearerSoft-delete your own side of a message. 404 if you are not a participant.
POST/api/dm/:id/restoreSession or BearerUndo your own-side soft-delete. 404 if you are not a participant.
POST/api/dm/images/uploadSession or BearerUpload an image attachment for a DM. Free (requires a verified email).
GET/api/dm/recipientsSession or BearerThe users you can DM (your mutual, approved followers).
GET/api/dm/thread/:usernameSession or BearerThe conversation with username. Marks its received-unread messages read on open.
GET/api/dm/unread-countSession or BearerCount of unread received DMs across all conversations.

Sending a message

POST /api/dm
Authorization: Bearer 3f1c9e...<64 hex chars>...a8
Content-Type: application/json

{
  "recipientId": "u2",
  "body": "Hey, great to connect here!",
  "imageUrls": []
}
FieldTypeDescription
recipientIdstringRequired. The user ID to message. Must be a mutual follower who has not blocked you.
bodystringRequired. Markdown source, 1–10000 characters (trimmed).
imageUrlsstring[]Optional. Up to 8 URLs previously returned by POST /api/dm/images/upload. Only your own uploaded image URLs are accepted.

Response (201): the created message under message:

{
  "message": {
    "id": "dm_001",
    "pairKey": "u1:u2",
    "senderId": "u1",
    "recipientId": "u2",
    "body": "Hey, great to connect here!",
    "imageUrls": [],
    "createdAt": "2025-06-11T09:00:00.000Z",
    "readAt": null,
    "sender": { "id": "u1", "username": "you", "displayName": "You", "avatar": null },
    "recipient": { "id": "u2", "username": "them", "displayName": "Them", "avatar": null },
    "preview": "Hey, great to connect here!"
  }
}

pairKey is a stable, sorted a:b anchor identical for both directions of a conversation. preview is a short plaintext (markdown-stripped) excerpt for list rendering. The recipient's per-side delete timestamps are never exposed: you cannot tell whether the other party has trashed their own copy.

Sending a DM fires an in-app notification to the recipient.

Listing folders

Each DM has an independent per-side soft-delete, so folders are personal:

  • inbox: messages you received and have not trashed.
  • sent: messages you sent and have not trashed.
  • deleted: messages you trashed on your own side (as sender or recipient).
GET /api/dm?folder=inbox&take=25

Folders are cursor-paginated newest-first. Pass the previous response's nextCursor back as cursor to fetch the next page:

{
  "items": [ { "id": "dm_010", "preview": "…", "createdAt": "…", "readAt": null, "...": "…" } ],
  "nextCursor": "dm_003"
}

nextCursor is null when there are no more pages. take is capped server-side at 50; higher values are clamped.

Reading a conversation

GET /api/dm/thread/them?take=25

Returns the conversation with the user resolved from the :username handle, in chronological order (oldest → newest, newest at the bottom). Opening a thread marks its received-unread messages read.

{
  "items": [ { "id": "dm_001", "senderId": "u1", "recipientId": "u2", "body": "…", "...": "…" } ],
  "olderCursor": "dm_001",
  "isMutual": true,
  "isBlocked": false,
  "otherUser": { "id": "u2", "username": "them", "displayName": "Them", "avatar": null }
}
  • olderCursor: pass back as cursor to page backward into older history; null at the start of the conversation. The first call (no cursor) returns the most recent page.
  • isMutual / isBlocked: the current follow/block state, so a client can show or hide the composer.

A 404 is returned when :username does not resolve to a user.

Read state and unread count

POST /api/dm/dm_001/read      # → { "updated": 1 }  (0 if not yours / already read)
GET  /api/dm/unread-count     # → { "count": 3 }

Only a recipient can mark a message read; a sender can never mark their own sent message read. unread-count totals unread received messages across every conversation (trashed messages excluded), suitable for a navbar badge.

Trash and restore

Trashing removes a message from your inbox/sent view only; the other participant still sees their copy.

POST /api/dm/dm_001/trash     # → { "ok": true }   (moves it to your Deleted folder)
POST /api/dm/dm_001/restore   # → { "ok": true }   (returns it to Inbox/Sent)

Both return 404 when you are not a participant. Trashing never changes a message's read state. A message trashed by both participants past a retention window is eventually purged permanently by a background job.

Uploading an image attachment

Two-step flow: upload first, then reference the returned URL in imageUrls when sending:

POST /api/dm/images/upload
Content-Type: multipart/form-data

file=<binary>

Response: { "url": "https://…/messages/…/…​.jpg" }

Unlike message image uploads, DM image uploads are not subscriber-gated: they are free. A verified email is still required (403 "Email verification required to post images."). Images are auto-resized (max 1200 px per side, ≤ ~1.4 MB); HEIC uploads from the web are rejected with 415 (use the iOS app, which converts HEIC to JPEG first).