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.

Who can message you ​

Each user picks who can start or send them DMs (User.dmPolicy, set through PUT /user and the app's Settings → Privacy):

dmPolicyWho can message them
everyone (default)Anyone
followingOnly people they follow
nobodyNo one, including existing conversations

A blocked attempt answers 403 DM_NOT_ALLOWED. It only governs incoming messages. The rule lives in assertCanMessage (apps/api/src/lib/messagingPolicy.ts), alongside the minors rule below.

Minors ​

Users can be 13 or older. When either side of a DM is under 18, or hasn't entered a birthdate yet, the two must follow each other (apps/api/src/lib/messagingPolicy.ts). Otherwise both POST /conversation and sending a message answer 403 with code MINOR_DM_RESTRICTED. The check runs on every send, so an unfollow (or an 18th birthday) takes effect immediately. Adults messaging adults are unaffected. The other age limits live with their features: e-dates (18+ and an approved ID), cash-outs (18+), sessions (acceptMinors, off by default) and tips (lower daily cap).

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. The emit is fire-and-forget: the message is already committed, so a realtime failure does not fail the request.

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 marks that thread's unread DIRECT_MESSAGE notifications read. It does not emit dm:read yet. The mobile conversation screen calls it with the newest message id on open and as live messages arrive, while foregrounded (useMarkConversationRead).

The inbox returns unreadCount per conversation: messages from the other side, not deleted, newer than the caller's lastReadAt.

Notifications while viewing ​

The API skips the DM notification (row, realtime emit, and push) for any recipient who has the thread open — see Realtime. DM pushes carry actorId, which the app uses to open the thread (the conversation route is keyed by the other user's id).

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 (201 if new, 200 if already a member)
POST/channels/:channelId/leaveLeave — { removed: boolean }
GET/channels/:channelId/membersMembers, paged (?limit=&cursor=); members only
POST/channels/:channelId/members/:userId/kickRemove a member (they can rejoin)
POST/channels/:channelId/members/:userId/banRemove and bar from rejoining, { reason? }
DELETE/channels/:channelId/members/:userId/banLift a ban
GET/channels/:channelId/bansBanned users (owners and admins)

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.

Leaving deletes the ChannelMember row and any ChannelRoomParticipant rows the user has in the channel's rooms. It is idempotent (removed: false if you weren't a member). The owner gets a 403; they delete the channel instead. GET /channels/:channelId returns isOwner alongside isMember so the app can hide Leave for owners.

In the app, join and leave update the channel screen and "My Channels" (channelKeys.list()) immediately, then refetch both (hooks/channel/membership-cache.ts).

Moderation: kick and ban ​

apps/api/src/services/channelModeration.ts. Kick, ban and unban need the KICK permission (lib/channel.ts: owners and admins). An actor can only remove someone ranked below them (owner > admin > moderator > member), so the owner can never be removed and an admin can't remove another admin.

Removing someone drops their ChannelMember and room-participant rows. It also takes them out of any live voice room (LiveKit removeParticipant on each room's uuid) and posts /admin/emit/channel/member/removed to esu. esu takes their sockets out of the channel's rooms and sends them channel:member:removed, so the app refreshes their channel lists. A ban (ChannelBan, unique per channel and user) also makes joinChannel answer 403 CHANNEL_BANNED and canAccessRoom refuse every room of the channel, public ones included.

In the app: Edit Channel → Members (app/(app)/(tabs)/chat/channel-members.tsx) lists members. Owners and admins get Remove / Ban on lower-ranked members, plus a Banned list with Unban.

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