Get Message Detail
Read a single message by id with all client-facing fields. Ownership-checked — you must be the sender or receiver. Content decrypted on read.
GET /api/v1/messages/:id — 🔑 Bearer · Rate limit: 60 req / minute · Kill-switched
Returns the full client-facing slice of a single Message row by id. Ownership-checked: you must be either senderId or receiverId; otherwise 403 not_authorized. Content is decrypted server-side on read (best-effort fallback to raw on decryption error).
This endpoint is implemented inline in the controller (no separate service method) — direct Prisma findUnique plus the ownership check plus safeDecrypt. Performance characteristic: a single indexed lookup, near-constant time.
Request
Path parameters
| Param | Type | Validation | Notes |
|---|---|---|---|
id | string | (no UUID pipe — raw string passthrough) | The Message.id. Whatever you pass is fed to findUnique directly. |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — ApiResponseOf<MessageDetailClientDto>
{
"success": true,
"data": {
"id": "m1a2b3c4-d5e6-7890-abcd-ef1234567890",
"content": "Hey, love your content!",
"status": "REPLIED",
"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",
"repliedAt": "2026-04-30T18:00:00.000Z",
"completedAt": null,
"timeoutHours": 72
}
}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 (snapshot at send time) |
priceSnapshot | string | null | Decimal as string. The price the fan paid at send time. null for FREE. |
senderId / receiverId | string (UUID) | Participants — you must be one of them |
createdAt | string (ISO 8601) | Send timestamp |
expiresAt | string (ISO 8601) | null | Reply window deadline (after this, system flips to EXPIRED + refunds the fan) |
repliedAt | string (ISO 8601) | null | When the creator replied (null if no reply yet) |
completedAt | string (ISO 8601) | null | When the message reached terminal COMPLETED state (escrow released to creator) |
timeoutHours | number | null | The reply deadline window in hours, captured at send time (used to compute expiresAt) |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
401 | (guard) | Missing / invalid bearer token |
403 | message.reply.error.not_authorized | You're neither senderId nor receiverId |
404 | message.reply.error.not_found | No Message with this id |
429 | (throttle) | Rate limit exceeded (60 req/min) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
prisma.message.findUnique({ where: { id: messageId }, select: { id, content, status, dmType, priceSnapshot, senderId, receiverId, createdAt, expiresAt, repliedAt, completedAt, timeoutHours } }).- Throw
not_foundif missing. - Ownership check:
senderId !== userId AND receiverId !== userId→ thrownot_authorized. - Decrypt
contentviasafeDecrypt(try / fallback to raw). - Return the row with decrypted content. No mutations.
Code samples
curl https://api.bio.re/api/v1/messages/m1a2b3c4-d5e6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $ACCESS_TOKEN"type MessageDetail = {
id: string;
content: string;
status: string;
dmType: 'FREE' | 'SINGLE_PAY' | 'PER_MESSAGE';
priceSnapshot: string | null;
senderId: string;
receiverId: string;
createdAt: string;
expiresAt: string | null;
repliedAt: string | null;
completedAt: string | null;
timeoutHours: number | null;
};
async function getMessage(accessToken: string, messageId: string): Promise<MessageDetail> {
const res = await fetch(`https://api.bio.re/api/v1/messages/${messageId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Message fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useQuery } from '@tanstack/react-query';
export const messageKeys = {
detail: (id: string) => ['messages', 'detail', id] as const,
};
export function useMessageDetail(messageId: string) {
return useQuery({
queryKey: messageKeys.detail(messageId),
queryFn: async () => {
const res = await fetch(`/api/v1/messages/${messageId}`);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Message fetch failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as MessageDetail;
},
enabled: Boolean(messageId),
staleTime: 10_000,
});
}Try it
Authorization
bearer In: header
Path Parameters
Response Body
application/json
application/json
application/json
application/json
curl -X GET "https://loading/api/v1/messages/string"{
"success": true,
"data": {
"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",
"repliedAt": "2019-08-24T14:15:22Z",
"completedAt": "2019-08-24T14:15:22Z",
"timeoutHours": 0
}
}{
"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"
}
}{
"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 (inline impl) | apps/api-core/src/modules/message/message.controller.ts | 135–158 (getMessage — direct Prisma + ownership check + safeDecrypt) |
| DTO (response) | apps/api-core/src/modules/message/dto/message-client-response.dto.ts | 165–201 (MessageDetailClientDto) |
| Encryption | apps/api-core/src/common/encryption.ts | decryptContent() |
| Prisma model | packages/prisma/prisma/schema.prisma | Message (enum MessageStatus, enum DmType) |
Get Unread Count
Lightweight badge counter — total unread messages where you're the receiver. Counts messages in PENDING / ESCROWED / DELIVERED states across all sessions.
Send Message
Fan sends a DM to a creator. Validates verification, block, DM config, vacation, dmType match, duplicate window, paid-DM concurrency cap, balance, then creates message + escrow atomically. Quarantines on moderation flag (silent to caller).