BIO.RE
Messages

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

ParamTypeDefaultValidationNotes
roleenumfanfan / creator (anything else falls back to fan)fan filters to sent messages, creator to received
sessionIdstring (UUID)โ€”UUID regex check (else 400)Scopes to a single chat session; participation enforced server-side (403 on mismatch)
pagenumber1parseInt, server-clamped to >= 11-based page index
limitnumber20parseInt, server-clamped to [1, 100]Items per page
HeaderRequiredNotes
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

FieldTypeNotes
idstring (UUID)Message.id
contentstringDecrypted on read (fallback to raw if decryption fails)
statusenumOne of: PENDING / ESCROWED / DELIVERED / READ / REPLIED / COMPLETED / EXPIRED / REFUNDED / REJECTED / QUARANTINED
dmTypeenumFREE / SINGLE_PAY / PER_MESSAGE (snapshot of the DM type at send time)
priceSnapshotstring | nullDecimal as string (the price paid at send time; null for FREE)
senderId / receiverIdstring (UUID)Participants
createdAtstring (ISO 8601)When the message was sent
expiresAtstring (ISO 8601) | nullWhen the creator's reply window closes (after which the system refunds + flips to EXPIRED)

Errors

HTTPcode / i18nKeyReason
400(validation)sessionId not a valid UUID
401(guard)Missing / invalid bearer token
403message.session.not_authorizedsessionId resolved a session you're not a participant in
404message.session.not_foundsessionId doesn't match any ChatSession row
429(throttle)Rate limit exceeded (60 req/min)
503features.messaging_disabledAdmin kill switch MESSAGING is active

Side effects

  1. sessionId validation โ€” UUID regex check at controller; service additionally verifies ChatSession exists AND user is fanId or creatorId.
  2. Clamp page = max(page, 1) and limit = clamp(limit, 1, 100).
  3. Build the where clause โ€” senderId = userId (role=fan) OR receiverId = userId (role=creator), +chatSessionId = sessionId if supplied.
  4. Two queries in parallel: findMany({ orderBy createdAt desc, skip, take, select: <inbox slice> }) + count({ where }).
  5. Decrypt each content via safeDecrypt() (try / fallback to raw on error).
  6. 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

GET
/api/v1/messages
AuthorizationBearer <token>

In: header

Query Parameters

role?string

View as fan (sent) or creator (received)

Value in"fan" | "creator"
sessionId?string

Filter by chat session ID (UUID)

page?number

Page number (1-based)

limit?number

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

SourcePathLines
Controllerapps/api-core/src/modules/message/message.controller.ts58โ€“85 (listMessages)
DTO (response item)apps/api-core/src/modules/message/dto/message-client-response.dto.ts21โ€“48 (MessageInboxItemDto)
Serviceapps/api-core/src/modules/message/message.service.ts927โ€“... (getInbox)
Encryptionapps/api-core/src/common/encryption.tsdecryptContent() (used via service safeDecrypt)
Kill switchapps/api-core/src/common/guards/kill-switch.guard.tsRequireKillSwitch('MESSAGING') (class-level)
Prisma modelspackages/prisma/prisma/schema.prismaMessage (enum MessageStatus, enum DmType), ChatSession (participation check)

On this page