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
| Param | Type | Required | Default | Validation | Notes |
|---|---|---|---|---|---|
query | string | โ | โ | โ | Search substring. Empty / missing โ server falls back to '' (matches everything; useful for browsing). |
page | number | optional | 1 | parseInt, server-clamped to >= 1 | 1-based page index |
limit | number | optional | 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<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
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Message.id |
content | string | Decrypted body (best-effort; falls back to raw on decryption failure) |
status | enum | One of PENDING / ESCROWED / DELIVERED / READ / REPLIED / COMPLETED / EXPIRED / REFUNDED / REJECTED / QUARANTINED |
dmType | enum | FREE / SINGLE_PAY / PER_MESSAGE |
senderId / receiverId | string (UUID) | Participants |
createdAt | string (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
| 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, 100). - Build
where:OR: [{ senderId: userId }, { receiverId: userId }] AND searchContent: { contains: query, mode: 'insensitive' }. - Two queries in parallel:
findMany({ orderBy createdAt desc, skip, take, select: <search slice> })+count({ where }). - Decrypt each
contentviasafeDecrypt(). - 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
Authorization
bearer In: header
Query Parameters
Search query string
Page number (1-based)
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/message/message.controller.ts | 89โ105 (searchMessages) |
| DTO (response item) | apps/api-core/src/modules/message/dto/message-client-response.dto.ts | 55โ76 (MessageSearchItemDto) |
| Service | apps/api-core/src/modules/message/message.service.ts | 812โ838 (searchMessages) |
| Encryption | apps/api-core/src/common/encryption.ts | decryptContent() |
| Prisma model | packages/prisma/prisma/schema.prisma | Message.content (encrypted), Message.searchContent (indexed search projection) |
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.
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.