Skip to content

Messaging & Channels

Two parallel systems: direct messages between two people, and channels owned by creators containing rooms. They share no models. All endpoints require authentication.

Messages are sent over REST and delivered over the socket — see Realtime.

Direct messages

MethodPathPurpose
POST/conversationCreate or fetch a DM with a user
GET/conversation/inboxList conversations
GET/conversation/:conversationId/messagesMessage history
POST/conversation/:conversationId/messagesSend a message
PUT/conversation/:conversationId/messages/:messageIdEdit
DELETE/conversation/:conversationId/messages/:messageIdDelete (soft)
POST/conversation/:conversationId/readMark read
POST/conversation/:conversationId/leaveLeave

POST /conversation

Create-or-get, not create. The service builds a deterministic dmKey from the two participant ids and upserts on it, so calling this repeatedly for the same pair always returns the same conversation.

Sending

json
{ "body": "hey", "type": "text" }

type is "text" or "shared_post"; a shared post sets sharedPostId. This is what powers share-to-DM from the feed.

After persisting, the API calls esu:

http
POST {REALTIME_SERVICE_URL}/admin/emit/dm/message/new
X-Realtime-Admin-Token:
{ "conversationId": "…", "message": {  } }

which emits dm:message:new into the dm:<conversationId> room.

Deletes are soft

ConversationMessage.deletedAt is set rather than the row being removed, so clients can render "message deleted" in place.

Read state

POST /conversation/:id/read updates ConversationReadState (lastReadAt, lastReadMsgId) for the caller and emits dm:read to the other participant. Unread counts are derived from this.

Channels

MethodPathPurpose
GET/channelsList channels
POST/channelsCreate (multipart/form-data, banner)
GET/channels/:channelIdOne channel
PUT/channels/:channelIdUpdate (multipart/form-data)
DELETE/channels/:channelIdDelete
POST/channels/:channelId/joinJoin

A channel belongs to one creator and contains rooms. Joining creates a ChannelMember row — membership alone does not grant access to vip rooms; that requires an active subscription.

Rooms

MethodPathPurpose
GET/channels/:channelId/roomsList rooms in a channel
POST/channels/:channelId/roomsCreate a room
GET/rooms/:roomIdOne room
PUT/rooms/:roomIdUpdate
DELETE/rooms/:roomIdDelete
GET/rooms/:roomId/messagesMessage history
POST/rooms/:roomId/messagesSend a message

Room types and visibility

typeMeaning
chatText
voiceLiveKit audio
videoLiveKit video
e-date1-on-1 session room
visibilityWho can enter
publicAnyone
members (default)The owner or a ChannelMember
vipA member with an active CreatorSubscription to the owner
privateOnly the two parties of the attached Session

Enforced by canAccessRoom in apps/api/src/lib/roomAccess.ts — the same function backs both the LiveKit token endpoint and the socket join authorization. See Livestreaming & Voice.

Voice and video rooms

MethodPathPurpose
GET/rooms/:id/tokenMint a LiveKit access token (403 if not permitted)
POST/rooms/:id/joinRecord a ChannelRoomParticipant
POST/rooms/:id/leaveRemove the participant record

GET /rooms/:id/token checks access, ensures the LiveKit room exists, and returns a token scoped to that room with a 1-hour TTL. The API is the only issuer of LiveKit tokens.

Interservice authorization

Used by esu, not by clients:

MethodPathPurpose
GET/dm/:conversationId/authzMay the caller join this DM?
GET/room/:roomId/authzMay the caller join this room?

Both require the user's own bearer token; esu forwards it. Any non-2xx becomes AUTHZ_DENIED on the socket.

Realtime events

EventWhen
dm:message:newA DM is sent
dm:message:edited / dm:message:deletedEdit or soft delete
dm:readRead receipt
dm:typingTyping indicator
room:message:newRoom message
channel:room:createdA room is added to a channel

Full contract, including the drift between the declared types and the wire format: Socket Events.

Internal documentation — PlayPalz platform