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
| Param | Type | Default | Validation | Notes |
|---|---|---|---|---|
page | number | 1 | parseInt, server-clamped to >= 1 | 1-based page index |
limit | number | 20 | parseInt, server-clamped to [1, 50] | Items per page (smaller cap than other list endpoints โ conversation rows are joined, expensive to assemble) |
| Header | Required | Notes |
|---|---|---|
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
| Field | Type | Notes |
|---|---|---|
sessionId | string (UUID) | ChatSession.id โ pass to GET /messages?sessionId=... to drill into the thread |
otherParty | object | { id, username, displayName } for the user on the other end of the session |
lastMessage | object | null | Last message in this session โ content is the literal '[encrypted]' sentinel (see callout). Null if the session has no messages yet. |
unreadCount | number | Count of messages where this user is the receiver AND status is PENDING / ESCROWED / DELIVERED (the "unread" trio) |
lastMessageAt | string (ISO 8601) | null | Most recent message timestamp on this session |
isBlocked | boolean | true when the calling user has blocked the other party (this user โ them) |
isBlockedBy | boolean | true when the other party has blocked the calling user (them โ this user) |
Top-level fields
| Field | Type | Notes |
|---|---|---|
items | array | Up to limit conversations, ordered by lastMessageAt DESC |
total | number | Total ChatSession rows matching this user (no filter beyond participation) |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
401 | (guard) | Missing / invalid bearer token |
429 | (throttle) | Rate limit exceeded (60 req/min) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
- Clamp
page = max(page, 1)andlimit = clamp(limit, 1, 50). - In parallel:
chatSession.findMany({ where: OR [fanId, creatorId], orderBy lastMessageAt desc, skip, take })+chatSession.count({ where }). - Build
otherPartyIdsarray (the non-self side of each session). - 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. - Build
userMap,blockedByMeSet,blockedMeSet(in-memory). - Per-session enrichment (inside
Promise.alloversessions):message.findFirst({ where chatSessionId, orderBy createdAt desc, select: id+content+dmType+status+createdAt+senderId })forlastMessage.message.count({ where chatSessionId AND receiverId = userId AND status IN [PENDING, ESCROWED, DELIVERED] })forunreadCount.- Resolve
otherPartyfromuserMap. - Replace
lastMessage.contentwith'[encrypted]'literal (no per-row decryption). - Stamp
isBlocked/isBlockedByfrom the sets.
- 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
Authorization
bearer In: header
Query Parameters
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/message/message.controller.ts | 109โ121 (getConversations) |
| DTO (response) | apps/api-core/src/modules/message/dto/message-client-response.dto.ts | 142โ148 (ConversationListResponseDto), 114โ135 (ConversationItemDto + nested ConversationOtherPartyDto / ConversationLastMessageDto) |
| Service | apps/api-core/src/modules/message/message.service.ts | 844โ913 (getConversations) |
| Prisma models | packages/prisma/prisma/schema.prisma | ChatSession, Message, User, BlockList |
Search Messages
Case-insensitive substring search across messages where you're the sender or receiver. Filters on the searchContent column (decrypted, indexed for search). Decrypts content for the response.
Get Unread Count
Lightweight badge counter โ total unread messages where you're the receiver. Counts messages in PENDING / ESCROWED / DELIVERED states across all sessions.