List Messages (Inbox)
Paginated inbox / sent view. Filter by role (fan = sent, creator = received) and optionally by chat session. Content is decrypted server-side per row.
GET /api/v1/messages โ ๐ Bearer ยท Rate limit: 60 req / minute ยท Kill-switched
Paginated message list. Role filter: fan returns messages the user sent (as a fan); creator returns messages they received (as a creator). Optional sessionId filter scopes to a single chat session โ when provided, the user must be a participant in that session (otherwise 403). Server decrypts each row's content on read (best-effort: if decryption fails, the raw stored value is returned).
Content is encrypted at rest. Each row's content field is decrypted server-side before returning. If the decryption helper throws (e.g. legacy plaintext or missing key), the original stored string is returned as a fallback so reads never fail outright. Don't expect a stable plaintext format โ display as user content, not as a structured field.
MESSAGING kill switch. All /messages/* endpoints share @RequireKillSwitch('MESSAGING'). Admin-disabled state returns 503 features.messaging_disabled (same shape across read + write endpoints). Surface a "messaging temporarily unavailable" UI on that response.
Request
Query parameters
| Param | Type | Default | Validation | Notes |
|---|---|---|---|---|
role | enum | fan | fan / creator (anything else falls back to fan) | fan filters to sent messages, creator to received |
sessionId | string (UUID) | โ | UUID regex check (else 400) | Scopes to a single chat session; participation enforced server-side (403 on mismatch) |
page | number | 1 | parseInt, server-clamped to >= 1 | 1-based page index |
limit | number | 20 | parseInt, server-clamped to [1, 100] | Items per page |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | โ | JWT from POST /auth/login |
Response
200 OK โ PaginatedApiResponseOf<MessageInboxItemDto>
{
"success": true,
"data": {
"items": [
{
"id": "m1a2b3c4-d5e6-7890-abcd-ef1234567890",
"content": "Hey, love your content!",
"status": "ESCROWED",
"dmType": "SINGLE_PAY",
"priceSnapshot": "5.00",
"senderId": "f0e1d2c3-b4a5-6789-0123-456789abcdef",
"receiverId": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
"createdAt": "2026-04-29T20:00:00.000Z",
"expiresAt": "2026-05-02T20:00:00.000Z"
}
],
"total": 42,
"page": 1,
"limit": 20,
"totalPages": 3
}
}Item fields
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Message.id |
content | string | Decrypted on read (fallback to raw if decryption fails) |
status | enum | One of: PENDING / ESCROWED / DELIVERED / READ / REPLIED / COMPLETED / EXPIRED / REFUNDED / REJECTED / QUARANTINED |
dmType | enum | FREE / SINGLE_PAY / PER_MESSAGE (snapshot of the DM type at send time) |
priceSnapshot | string | null | Decimal as string (the price paid at send time; null for FREE) |
senderId / receiverId | string (UUID) | Participants |
createdAt | string (ISO 8601) | When the message was sent |
expiresAt | string (ISO 8601) | null | When the creator's reply window closes (after which the system refunds + flips to EXPIRED) |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | (validation) | sessionId not a valid UUID |
401 | (guard) | Missing / invalid bearer token |
403 | message.session.not_authorized | sessionId resolved a session you're not a participant in |
404 | message.session.not_found | sessionId doesn't match any ChatSession row |
429 | (throttle) | Rate limit exceeded (60 req/min) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
sessionIdvalidation โ UUID regex check at controller; service additionally verifiesChatSessionexists AND user isfanIdorcreatorId.- Clamp
page = max(page, 1)andlimit = clamp(limit, 1, 100). - Build the
whereclause โsenderId = userId(role=fan) ORreceiverId = userId(role=creator),+chatSessionId = sessionIdif supplied. - Two queries in parallel:
findMany({ orderBy createdAt desc, skip, take, select: <inbox slice> })+count({ where }). - Decrypt each
contentviasafeDecrypt()(try / fallback to raw on error). - Return
{ items, total, page, limit, totalPages }. No mutations.
Code samples
# fan inbox (sent messages)
curl 'https://api.bio.re/api/v1/messages?role=fan&page=1&limit=20' \
-H "Authorization: Bearer $ACCESS_TOKEN"
# creator inbox (received messages, scoped to one session)
curl 'https://api.bio.re/api/v1/messages?role=creator&sessionId=s1a2b3c4-d5e6-7890-abcd-ef1234567890' \
-H "Authorization: Bearer $ACCESS_TOKEN"type MessageStatus =
| 'PENDING' | 'ESCROWED' | 'DELIVERED' | 'READ'
| 'REPLIED' | 'COMPLETED' | 'EXPIRED'
| 'REFUNDED' | 'REJECTED' | 'QUARANTINED';
type DmType = 'FREE' | 'SINGLE_PAY' | 'PER_MESSAGE';
type MessageInboxItem = {
id: string;
content: string;
status: MessageStatus;
dmType: DmType;
priceSnapshot: string | null;
senderId: string;
receiverId: string;
createdAt: string;
expiresAt: string | null;
};
type Paginated<T> = {
items: T[];
total: number;
page: number;
limit: number;
totalPages: number;
};
async function listMessages(
accessToken: string,
filter: { role?: 'fan' | 'creator'; sessionId?: string; page?: number; limit?: number } = {},
): Promise<Paginated<MessageInboxItem>> {
const url = new URL('https://api.bio.re/api/v1/messages');
if (filter.role) url.searchParams.set('role', filter.role);
if (filter.sessionId) url.searchParams.set('sessionId', filter.sessionId);
if (filter.page) url.searchParams.set('page', String(filter.page));
if (filter.limit) url.searchParams.set('limit', String(filter.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 ?? 'Inbox fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useQuery, keepPreviousData } from '@tanstack/react-query';
export const messageKeys = {
list: (filter: { role?: string; sessionId?: string; page?: number; limit?: number }) =>
['messages', 'list', filter] as const,
};
export function useMessageList(filter: {
role?: 'fan' | 'creator';
sessionId?: string;
page?: number;
limit?: number;
} = {}) {
return useQuery({
queryKey: messageKeys.list(filter),
queryFn: async () => {
const url = new URL('/api/v1/messages', window.location.origin);
if (filter.role) url.searchParams.set('role', filter.role);
if (filter.sessionId) url.searchParams.set('sessionId', filter.sessionId);
if (filter.page) url.searchParams.set('page', String(filter.page));
if (filter.limit) url.searchParams.set('limit', String(filter.limit));
const res = await fetch(url);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Inbox fetch failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as Paginated<MessageInboxItem>;
},
placeholderData: keepPreviousData, // smooth pagination
staleTime: 10_000, // refetch frequently โ DM status flips on webhook events
});
}Try it
Authorization
bearer In: header
Query Parameters
View as fan (sent) or creator (received)
"fan" | "creator"Filter by chat session ID (UUID)
Page number (1-based)
Items per page (max 100)
Response Body
application/json
application/json
application/json
curl -X GET "https://loading/api/v1/messages"{
"success": true,
"data": {
"items": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"content": "Hey, love your content!",
"status": "PENDING",
"dmType": "FREE",
"priceSnapshot": "25.00",
"senderId": "6b2f63ba-164c-48c9-87b1-690cee2b3da3",
"receiverId": "2ec2e5a9-5968-4568-baf3-a525f7f8b9a6",
"createdAt": "2019-08-24T14:15:22Z",
"expiresAt": "2019-08-24T14:15:22Z"
}
],
"page": 1,
"limit": 20,
"total": 150,
"totalPages": 8
}
}{
"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"
}
}{
"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 | 58โ85 (listMessages) |
| DTO (response item) | apps/api-core/src/modules/message/dto/message-client-response.dto.ts | 21โ48 (MessageInboxItemDto) |
| Service | apps/api-core/src/modules/message/message.service.ts | 927โ... (getInbox) |
| Encryption | apps/api-core/src/common/encryption.ts | decryptContent() (used via service safeDecrypt) |
| Kill switch | apps/api-core/src/common/guards/kill-switch.guard.ts | RequireKillSwitch('MESSAGING') (class-level) |
| Prisma models | packages/prisma/prisma/schema.prisma | Message (enum MessageStatus, enum DmType), ChatSession (participation check) |
Get Terms of Service
Public read of the platform terms of service. Content is admin-managed via ConfigService. Same shape as the privacy policy endpoint.
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.