BIO.RE
Messages

List Conversations

Paginated chat session list with the other party's profile slice, last message preview (encrypted placeholder), unread count per session, and bidirectional block flags.

GET /api/v1/messages/conversations โ€” ๐Ÿ”‘ Bearer ยท Rate limit: 60 req / minute ยท Kill-switched

Returns the user's ChatSession rows (where they're either fanId or creatorId), enriched with the other party's profile slice, the last message preview (with content as '[encrypted]' placeholder), per-session unread count, and bidirectional block flags (isBlocked = you blocked them; isBlockedBy = they blocked you).

Last message content is '[encrypted]' literal. The conversations list does NOT decrypt the last message body โ€” performance bound (one decryption per row ร— pagination would dominate cost). The string '[encrypted]' is a sentinel; render a generic preview (e.g. "New message from <displayName>") rather than displaying it. To get the actual content, fetch the full message via GET /messages/:id or the inbox via GET /messages?sessionId=....

Block flags are bidirectional and computed in the same query batch. The frontend uses these to toggle the chat into read-only mode without an extra round-trip. isBlocked: true โ†’ you blocked them (you can unblock); isBlockedBy: true โ†’ they blocked you (no action available).

Request

Query parameters

ParamTypeDefaultValidationNotes
pagenumber1parseInt, server-clamped to >= 11-based page index
limitnumber20parseInt, server-clamped to [1, 50]Items per page (smaller cap than other list endpoints โ€” conversation rows are joined, expensive to assemble)
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login

Response

200 OK โ€” ApiResponseOf<ConversationListResponseDto>

{
  "success": true,
  "data": {
    "items": [
      {
        "sessionId": "s1a2b3c4-d5e6-7890-abcd-ef1234567890",
        "otherParty": {
          "id": "u1a2b3c4-d5e6-7890-abcd-ef1234567890",
          "username": "johndoe",
          "displayName": "John Doe"
        },
        "lastMessage": {
          "id": "m1a2b3c4-d5e6-7890-abcd-ef1234567890",
          "content": "[encrypted]",
          "dmType": "SINGLE_PAY",
          "status": "REPLIED",
          "createdAt": "2026-04-29T20:00:00.000Z",
          "senderId": "u1a2b3c4-d5e6-7890-abcd-ef1234567890"
        },
        "unreadCount": 3,
        "lastMessageAt": "2026-04-29T20:00:00.000Z",
        "isBlocked": false,
        "isBlockedBy": false
      }
    ],
    "total": 12
  }
}

Item fields

FieldTypeNotes
sessionIdstring (UUID)ChatSession.id โ€” pass to GET /messages?sessionId=... to drill into the thread
otherPartyobject{ id, username, displayName } for the user on the other end of the session
lastMessageobject | nullLast message in this session โ€” content is the literal '[encrypted]' sentinel (see callout). Null if the session has no messages yet.
unreadCountnumberCount of messages where this user is the receiver AND status is PENDING / ESCROWED / DELIVERED (the "unread" trio)
lastMessageAtstring (ISO 8601) | nullMost recent message timestamp on this session
isBlockedbooleantrue when the calling user has blocked the other party (this user โ†’ them)
isBlockedBybooleantrue when the other party has blocked the calling user (them โ†’ this user)

Top-level fields

FieldTypeNotes
itemsarrayUp to limit conversations, ordered by lastMessageAt DESC
totalnumberTotal ChatSession rows matching this user (no filter beyond participation)

Errors

HTTPcode / i18nKeyReason
401(guard)Missing / invalid bearer token
429(throttle)Rate limit exceeded (60 req/min)
503features.messaging_disabledAdmin kill switch MESSAGING is active

Side effects

  1. Clamp page = max(page, 1) and limit = clamp(limit, 1, 50).
  2. In parallel: chatSession.findMany({ where: OR [fanId, creatorId], orderBy lastMessageAt desc, skip, take }) + chatSession.count({ where }).
  3. Build otherPartyIds array (the non-self side of each session).
  4. In parallel: user.findMany({ id IN otherPartyIds, select id+username+displayName }) + blockList.findMany({ ownerId: userId, blockedId IN otherPartyIds }) + blockList.findMany({ ownerId IN otherPartyIds, blockedId: userId }) โ€” three batch lookups, one round-trip each.
  5. Build userMap, blockedByMeSet, blockedMeSet (in-memory).
  6. Per-session enrichment (inside Promise.all over sessions):
    • message.findFirst({ where chatSessionId, orderBy createdAt desc, select: id+content+dmType+status+createdAt+senderId }) for lastMessage.
    • message.count({ where chatSessionId AND receiverId = userId AND status IN [PENDING, ESCROWED, DELIVERED] }) for unreadCount.
    • Resolve otherParty from userMap.
    • Replace lastMessage.content with '[encrypted]' literal (no per-row decryption).
    • Stamp isBlocked / isBlockedBy from the sets.
  7. Return { items, total }. No mutations.

Code samples

curl 'https://api.bio.re/api/v1/messages/conversations?page=1&limit=20' \
  -H "Authorization: Bearer $ACCESS_TOKEN"
type ConversationOtherParty = {
  id: string;
  username: string | null;
  displayName: string | null;
};

type ConversationLastMessage = {
  id: string;
  content: string; // always '[encrypted]' here
  dmType: 'FREE' | 'SINGLE_PAY' | 'PER_MESSAGE';
  status: string;
  createdAt: string;
  senderId: string;
};

type ConversationItem = {
  sessionId: string;
  otherParty: ConversationOtherParty;
  lastMessage: ConversationLastMessage | null;
  unreadCount: number;
  lastMessageAt: string | null;
  isBlocked: boolean;
  isBlockedBy: boolean;
};

type ConversationList = {
  items: ConversationItem[];
  total: number;
};

async function listConversations(
  accessToken: string,
  page = 1,
  limit = 20,
): Promise<ConversationList> {
  const url = new URL('https://api.bio.re/api/v1/messages/conversations');
  url.searchParams.set('page', String(page));
  url.searchParams.set('limit', String(limit));
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? 'Conversations fetch failed'), {
      code: json?.error?.code,
    });
  }
  return json.data;
}
import { useQuery, keepPreviousData } from '@tanstack/react-query';

export const messageKeys = {
  conversations: (page: number, limit: number) =>
    ['messages', 'conversations', page, limit] as const,
};

export function useConversations(page = 1, limit = 20) {
  return useQuery({
    queryKey: messageKeys.conversations(page, limit),
    queryFn: async () => {
      const url = new URL('/api/v1/messages/conversations', window.location.origin);
      url.searchParams.set('page', String(page));
      url.searchParams.set('limit', String(limit));
      const res = await fetch(url);
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Conversations fetch failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
        });
      }
      return json.data as ConversationList;
    },
    placeholderData: keepPreviousData,
    staleTime: 10_000, // refresh frequently โ€” new messages flip unreadCount
  });
}

Try it

GET
/api/v1/messages/conversations
AuthorizationBearer <token>

In: header

Query Parameters

page?string
limit?string

Response Body

application/json

application/json

curl -X GET "https://loading/api/v1/messages/conversations"
{
  "success": true,
  "data": {
    "items": [
      {
        "sessionId": "f6567dd8-e069-418e-8893-7d22fcf12459",
        "otherParty": {
          "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
          "username": "johndoe",
          "displayName": "John Doe"
        },
        "lastMessage": {
          "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
          "content": "Hey, love your content!",
          "dmType": "FREE",
          "status": "PENDING",
          "createdAt": "2019-08-24T14:15:22Z",
          "senderId": "6b2f63ba-164c-48c9-87b1-690cee2b3da3"
        },
        "unreadCount": 3,
        "lastMessageAt": "2019-08-24T14:15:22Z",
        "isBlocked": false,
        "isBlockedBy": false
      }
    ],
    "total": 42
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/message/message.controller.ts109โ€“121 (getConversations)
DTO (response)apps/api-core/src/modules/message/dto/message-client-response.dto.ts142โ€“148 (ConversationListResponseDto), 114โ€“135 (ConversationItemDto + nested ConversationOtherPartyDto / ConversationLastMessageDto)
Serviceapps/api-core/src/modules/message/message.service.ts844โ€“913 (getConversations)
Prisma modelspackages/prisma/prisma/schema.prismaChatSession, Message, User, BlockList

On this page