Help
Help
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:
| Condition | Status | error code |
|---|---|---|
| Recipient is yourself | 400 | self_message |
| Recipient does not exist | 404 | recipient_not_found |
| Either user blocks the other | 403 | blocked |
| You are not mutual followers | 403 | not_mutual |
| Body missing / empty / too long | 400 | invalid_body |
| Attachments invalid | 400 | invalid_images |
Use GET /api/dm/recipients to fetch exactly the set of users you are currently allowed to message.
Endpoint table
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/dm | Session or Bearer | List one of your DM folders. Query: folder (inbox | sent | deleted, default inbox), cursor, take. |
| POST | /api/dm | Session or Bearer | Send a direct message. Body: { recipientId, body, imageUrls? }. Returns 201. |
| GET | /api/dm/:id | Session or Bearer | Fetch a single message you participate in. 404 if you are not a participant. |
| POST | /api/dm/:id/read | Session or Bearer | Mark a received message read (recipient-scoped). Returns { updated }. |
| POST | /api/dm/:id/trash | Session or Bearer | Soft-delete your own side of a message. 404 if you are not a participant. |
| POST | /api/dm/:id/restore | Session or Bearer | Undo your own-side soft-delete. 404 if you are not a participant. |
| POST | /api/dm/images/upload | Session or Bearer | Upload an image attachment for a DM. Free (requires a verified email). |
| GET | /api/dm/recipients | Session or Bearer | The users you can DM (your mutual, approved followers). |
| GET | /api/dm/thread/:username | Session or Bearer | The conversation with username. Marks its received-unread messages read on open. |
| GET | /api/dm/unread-count | Session or Bearer | Count 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": []
}
| Field | Type | Description |
|---|---|---|
recipientId | string | Required. The user ID to message. Must be a mutual follower who has not blocked you. |
body | string | Required. Markdown source, 1–10000 characters (trimmed). |
imageUrls | string[] | 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 ascursorto page backward into older history;nullat 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).
Related
- Direct Messages (Help Center): the user-facing guide to private conversations.
- Following: the mutual-follow relationship that DMs require.
- API overview: base URL, authentication at a glance, and response conventions.
- API explorer: try direct message endpoints live with the interactive Swagger console.