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).
POST /api/v1/messages โ ๐ Bearer ยท Rate limit: 10 req / minute ยท Kill-switched
The most complex endpoint in the message module. Validates a long chain of preconditions (sender email verification, receiver active, block list, DM enabled, no vacation, dmType matches creator's config, no duplicate-window collision, no pending paid-DM concurrency violation, sufficient wallet balance for paid DMs), then atomically creates the Message, escrow record (paid DMs), and MessageRuleSnapshot. Content moderation runs after persistence; flagged messages get quarantined (silent to the fan โ they see "sent").
Quarantine is silent. When the moderation rule engine flags a message, the server transitions it to QUARANTINED server-side but returns status: PENDING to the caller (intentional โ fan doesn't know they were flagged). The status only flips for admin / creator visibility. The DSA Article 17 "Statement of Reasons" is emitted server-side as audit trail.
Status returned by this endpoint is one of:
PENDINGโ paid DM where moderation flagged it (silently quarantined; status surface lies to the caller)ESCROWEDโ paid DM that passed moderation (escrow created, awaiting creator)DELIVEREDโ free DM that passed moderation (no escrow path)
For paid DMs, escrow is created BEFORE moderation runs so quarantined paid messages still have escrow available for the admin approval flow.
Free DMs are doubly rate-limited. Beyond the endpoint's 10/min throttle, free DMs hit two per-day Redis counters: dm.free_daily_limit (default 5/user/day) and dm.free_per_creator_daily (default 1/user/creator/day), keyed by UTC date. Limits reset at UTC midnight. If Redis is unavailable, free DMs are rejected (security-first โ can't enforce limits without it).
Request
Body โ SendMessageDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
receiverId | string | โ | IsString() (NOT UUID-pipe; raw passthrough) | Creator's user id |
content | string | โ | MinLength(1), MaxLength(2000) | Encrypted at rest; first 500 chars projected to searchContent for FTS |
dmType | enum | โ | IsEnum(DmType) | FREE / SINGLE_PAY / PER_MESSAGE โ must match the creator's dmType config (else 400) |
price | string (decimal) | conditional | Matches(/^\d+(\.\d{1,2})?$/) | Required when dmType !== 'FREE'. Decimal as string. Server enforces >= active DMPackage.price for the creator. |
timeoutHours | number | optional | Min(1), Max(720) | Reply-window deadline; defaults to dm.timeout_hours config (default 48). After expiry, system flips to EXPIRED + refunds. |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | โ | JWT from POST /auth/login |
Response
201 Created โ ApiResponseOf<SendMessageResponseDto>
{
"success": true,
"data": {
"messageId": "m1a2b3c4-d5e6-7890-abcd-ef1234567890",
"status": "ESCROWED"
}
}| Field | Type | Notes |
|---|---|---|
messageId | string (UUID) | The new Message.id |
status | enum | PENDING (silently-quarantined paid) / ESCROWED (paid OK) / DELIVERED (free OK) |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | message.send.error.self_message | senderId === receiverId |
400 | message.send.error.empty_content | Content empty after .trim() |
400 | message.send.error.creator_unavailable | Receiver missing OR User.status !== ACTIVE |
400 | message.send.error.dm_disabled | Creator has no CreatorProfile OR dmActive: false |
400 | message.send.error.vacation | Creator has vacationMode: true |
400 | message.send.error.dm_type_mismatch | Submitted dmType doesn't match creator's configured dmType |
400 | message.send.error.duplicate | Same senderId + receiverId + searchContent within messaging.duplicate_window_seconds (default 60s) |
400 | message.send.error.free_dm_daily_limit | dm.free_daily_limit exhausted (UTC day) |
400 | message.send.error.free_dm_per_creator_limit | dm.free_per_creator_daily exhausted for this creator (UTC day) |
400 | message.send.error.service_unavailable | Redis cache offline (free DMs only โ security-first reject) |
400 | message.send.error.price_below_minimum | Submitted price < activeDMPackage.price |
400 | message.send.error.pending_paid_exists | Already have a PENDING/ESCROWED paid DM to this same creator |
400 | payment.escrow.wallet_unavailable | No FAN wallet OR frozen: true (paid DMs) |
400 | payment.escrow.insufficient_balance | wallet.balance < price (paid DMs) |
401 | (guard) | Missing / invalid bearer token |
403 | message.send.error.email_not_verified | Sender hasn't verified email |
403 | message.send.error.blocked | Receiver has blocked sender (BlockList check) |
429 | (throttle) | Rate limit exceeded (10 req/min) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
- Self-message guard + content non-empty.
- Sender email verification gate (404/403 if missing or unverified).
- Receiver gate โ must exist AND
User.status = ACTIVE. - Block check โ
BlockList { ownerId: receiverId, blockedId: senderId }. - Creator DM config โ
CreatorProfile.dmActiveAND notvacationModeANDdmTypematches. - Duplicate window โ
searchContentprojection match withinmessaging.duplicate_window_seconds. - Free DM rate limits (when
dmType = FREE) โ Redis INCR with TTL onfree_dm:<senderId>:<utcDate>ANDfree_dm:<senderId>:<receiverId>:<utcDate>. Rollback DECR on over-limit. Reject if Redis offline. - Get-or-create
ChatSessionfor the (fan, creator) pair (bidirectional match). - Resolve
timeoutHoursโ body ordm.timeout_hoursdefault. - Paid DM extra checks (when
dmType !== 'FREE' && price > 0):price >= activeDMPackage.price(Decimal comparison).- Pending paid concurrency cap โ count of
PENDING/ESCROWEDpaid DMs from sender to receiver must be0. - Pre-escrow balance check โ wallet exists, not frozen,
balance >= price. - Resolve
commissionRatefromcreator.commission_<level>config (per creator level).
- Atomic create: insert
Message(encryptedcontent, plaintextsearchContent,priceSnapshot,commissionRate,expiresAt). - Create escrow for paid DMs via
escrowService.createEscrowโ on failure, delete the orphan message + new chat session for recovery. - Snapshot โ
MessageRuleSnapshot.create(fire-and-forget โ failure logs but doesn't block). - Moderation โ
moderateContent(content)against activeModerationRulerows (Redis-cached, DB fallback, hardcoded fallback). On flag:- Transition message to
QUARANTINED. - Emit DSA Art. 17
sorService.emit(fire-and-forget audit). - Return
{ messageId, status: 'PENDING' }to the fan (silent quarantine).
- Transition message to
- Status transition (no flag) โ paid โ
ESCROWED, free โDELIVERED. - Notify creator via
notificationService.send({ eventKey: 'new_message_creator', deepLink: '/messages/<sessionId>' })(fire-and-forget; failure logged). - Update
ChatSession.lastMessageAtfor conversation ordering. - Increment
messageSentTotalPrometheus counter. - Return
{ messageId, status }.
Code samples
# Free DM
curl -X POST https://api.bio.re/api/v1/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"receiverId": "u1a2b3c4-d5e6-7890-abcd-ef1234567890",
"content": "Loved your latest post!",
"dmType": "FREE"
}'
# Paid DM
curl -X POST https://api.bio.re/api/v1/messages \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"receiverId": "u1a2b3c4-d5e6-7890-abcd-ef1234567890",
"content": "Quick question about your service.",
"dmType": "SINGLE_PAY",
"price": "5.00",
"timeoutHours": 48
}'type DmType = 'FREE' | 'SINGLE_PAY' | 'PER_MESSAGE';
type SendMessageInput = {
receiverId: string;
content: string; // 1-2000 chars
dmType: DmType;
price?: string; // required when dmType !== 'FREE'
timeoutHours?: number; // 1-720, default from dm.timeout_hours admin config
};
type SendMessageResult = {
messageId: string;
status: 'PENDING' | 'ESCROWED' | 'DELIVERED';
};
async function sendMessage(accessToken: string, input: SendMessageInput): Promise<SendMessageResult> {
const res = await fetch('https://api.bio.re/api/v1/messages', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Send failed'), {
code: json?.error?.code,
});
}
return json.data;
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useSendMessage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: SendMessageInput) => {
const res = await fetch('/api/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Send failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
});
}
return json.data as SendMessageResult;
},
onSuccess: () => {
// Multi-resource invalidation โ message lists, conversations, unread, balance (paid DMs)
qc.invalidateQueries({ queryKey: ['messages'] });
qc.invalidateQueries({ queryKey: ['wallet', 'balance'] });
qc.invalidateQueries({ queryKey: ['wallet', 'activity'] });
},
});
}Try it
Authorization
bearer In: header
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/messages" \ -H "Content-Type: application/json" \ -d '{ "receiverId": "cuid_creator_123", "content": "Hey, loved your latest post!", "dmType": "FREE" }'{
"success": true,
"data": {
"messageId": "8540d774-4863-4d2b-b788-4ecb19412e85",
"status": "PENDING"
}
}{
"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"
}
}{
"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 | 34โ54 (sendMessage) |
| DTO (request) | apps/api-core/src/modules/message/dto/index.ts | 5โ21 (SendMessageDto) |
| DTO (response) | apps/api-core/src/modules/message/dto/message-client-response.dto.ts | 8โ14 (SendMessageResponseDto) |
| Service | apps/api-core/src/modules/message/message.service.ts | 93โ348 (sendMessage), 541โ584 (enforceFreeDmLimits), 488โ528 (moderateContent), 360โ419 (transition + getValidSourceStatuses) |
| Escrow | apps/api-core/src/modules/payment/escrow.service.ts | createEscrow() |
| Encryption | apps/api-core/src/common/encryption.ts | encryptContent() |
| Notification pipeline | apps/api-core/src/modules/notification/notification.service.ts | send({ eventKey: 'new_message_creator' }) |
| Config keys | apps/api-core/src/modules/config/config.service.ts | dm.timeout_hours, dm.free_daily_limit, dm.free_per_creator_daily, messaging.duplicate_window_seconds, creator.commission_<level> (admin-managed) |
| Prisma models | packages/prisma/prisma/schema.prisma | Message, ChatSession, Wallet (FAN, escrow check), BlockList, CreatorProfile.dm*, DMPackage.price, MessageRuleSnapshot, Escrow, ModerationRule, MessageStatusLog |
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.
Reply to Message
Creator-only reply. Requires ESCROWED or DELIVERED status. Atomically transitions REPLIEDโCOMPLETED + releases escrow (paid) or skips (free). Compensates on escrow-release failure.