BIO.RE
Messages

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 /api/v1/messages/search โ€” ๐Ÿ”‘ Bearer ยท Rate limit: 60 req / minute ยท Kill-switched

Substring search (case-insensitive) over the user's own messages โ€” both sent and received. Searches the Message.searchContent column (an indexed, plaintext-for-search projection of the encrypted content). Returns paginated results with content decrypted for display.

searchContent is the search index, content is the encrypted body. The server stores message bodies encrypted in content and a searchable plaintext / normalized projection in searchContent. Search runs against searchContent (Prisma contains + mode: 'insensitive') โ€” clients never see this field, only the decrypted content.

No tokenization, no ranking. This is a substring LIKE search, not full-text. query="hello" matches "Hello there" and "hellothere"; it doesn't tokenize, doesn't stem, doesn't rank. Pass the raw user input โ€” server normalizes via mode: 'insensitive' only.

Request

Query parameters

ParamTypeRequiredDefaultValidationNotes
querystringโœ“โ€”โ€”Search substring. Empty / missing โ†’ server falls back to '' (matches everything; useful for browsing).
pagenumberoptional1parseInt, server-clamped to >= 11-based page index
limitnumberoptional20parseInt, server-clamped to [1, 100]Items per page
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login

Response

200 OK โ€” PaginatedApiResponseOf<MessageSearchItemDto>

{
  "success": true,
  "data": {
    "items": [
      {
        "id": "m1a2b3c4-d5e6-7890-abcd-ef1234567890",
        "content": "Hey, love your content!",
        "status": "REPLIED",
        "dmType": "SINGLE_PAY",
        "senderId": "f0e1d2c3-b4a5-6789-0123-456789abcdef",
        "receiverId": "c1a2b3c4-d5e6-7890-abcd-ef1234567890",
        "createdAt": "2026-04-29T20:00:00.000Z"
      }
    ],
    "total": 7,
    "page": 1,
    "limit": 20,
    "totalPages": 1
  }
}

Item fields

FieldTypeNotes
idstring (UUID)Message.id
contentstringDecrypted body (best-effort; falls back to raw on decryption failure)
statusenumOne of PENDING / ESCROWED / DELIVERED / READ / REPLIED / COMPLETED / EXPIRED / REFUNDED / REJECTED / QUARANTINED
dmTypeenumFREE / SINGLE_PAY / PER_MESSAGE
senderId / receiverIdstring (UUID)Participants
createdAtstring (ISO 8601)Message timestamp

Note: search results do not include priceSnapshot / expiresAt (slimmer projection vs MessageInboxItemDto). Pull the full detail via GET /messages/:id when needed.

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, 100).
  2. Build where: OR: [{ senderId: userId }, { receiverId: userId }] AND searchContent: { contains: query, mode: 'insensitive' }.
  3. Two queries in parallel: findMany({ orderBy createdAt desc, skip, take, select: <search slice> }) + count({ where }).
  4. Decrypt each content via safeDecrypt().
  5. Return { items, total, page, limit, totalPages }. No mutations.

Code samples

curl 'https://api.bio.re/api/v1/messages/search?query=love&page=1&limit=20' \
  -H "Authorization: Bearer $ACCESS_TOKEN"
type MessageSearchItem = {
  id: string;
  content: string;
  status: string;
  dmType: 'FREE' | 'SINGLE_PAY' | 'PER_MESSAGE';
  senderId: string;
  receiverId: string;
  createdAt: string;
};

async function searchMessages(
  accessToken: string,
  query: string,
  page = 1,
  limit = 20,
): Promise<Paginated<MessageSearchItem>> {
  const url = new URL('https://api.bio.re/api/v1/messages/search');
  url.searchParams.set('query', query);
  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 ?? 'Search failed'), {
      code: json?.error?.code,
    });
  }
  return json.data;
}
import { useQuery, keepPreviousData } from '@tanstack/react-query';

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

export function useMessageSearch(query: string, page = 1, limit = 20) {
  // Caller is responsible for debouncing the query input upstream
  return useQuery({
    queryKey: messageKeys.search(query, page, limit),
    queryFn: async () => {
      const url = new URL('/api/v1/messages/search', window.location.origin);
      url.searchParams.set('query', query);
      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 ?? 'Search failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
        });
      }
      return json.data as Paginated<MessageSearchItem>;
    },
    enabled: query.length >= 2,
    placeholderData: keepPreviousData,
    staleTime: 30_000,
  });
}

Try it

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

In: header

Query Parameters

query*string

Search query string

page?number

Page number (1-based)

limit?number

Items per page (max 100)

Response Body

application/json

application/json

curl -X GET "https://loading/api/v1/messages/search?query=string"
{
  "success": true,
  "data": {
    "items": [
      {
        "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
        "content": "Hey, love your content!",
        "status": "PENDING",
        "dmType": "FREE",
        "senderId": "6b2f63ba-164c-48c9-87b1-690cee2b3da3",
        "receiverId": "2ec2e5a9-5968-4568-baf3-a525f7f8b9a6",
        "createdAt": "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"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/message/message.controller.ts89โ€“105 (searchMessages)
DTO (response item)apps/api-core/src/modules/message/dto/message-client-response.dto.ts55โ€“76 (MessageSearchItemDto)
Serviceapps/api-core/src/modules/message/message.service.ts812โ€“838 (searchMessages)
Encryptionapps/api-core/src/common/encryption.tsdecryptContent()
Prisma modelpackages/prisma/prisma/schema.prismaMessage.content (encrypted), Message.searchContent (indexed search projection)

On this page