BIO.RE
Messages

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

FieldTypeRequiredValidationNotes
receiverIdstringโœ“IsString() (NOT UUID-pipe; raw passthrough)Creator's user id
contentstringโœ“MinLength(1), MaxLength(2000)Encrypted at rest; first 500 chars projected to searchContent for FTS
dmTypeenumโœ“IsEnum(DmType)FREE / SINGLE_PAY / PER_MESSAGE โ€” must match the creator's dmType config (else 400)
pricestring (decimal)conditionalMatches(/^\d+(\.\d{1,2})?$/)Required when dmType !== 'FREE'. Decimal as string. Server enforces >= active DMPackage.price for the creator.
timeoutHoursnumberoptionalMin(1), Max(720)Reply-window deadline; defaults to dm.timeout_hours config (default 48). After expiry, system flips to EXPIRED + refunds.
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login

Response

201 Created โ€” ApiResponseOf<SendMessageResponseDto>

{
  "success": true,
  "data": {
    "messageId": "m1a2b3c4-d5e6-7890-abcd-ef1234567890",
    "status": "ESCROWED"
  }
}
FieldTypeNotes
messageIdstring (UUID)The new Message.id
statusenumPENDING (silently-quarantined paid) / ESCROWED (paid OK) / DELIVERED (free OK)

Errors

HTTPcode / i18nKeyReason
400message.send.error.self_messagesenderId === receiverId
400message.send.error.empty_contentContent empty after .trim()
400message.send.error.creator_unavailableReceiver missing OR User.status !== ACTIVE
400message.send.error.dm_disabledCreator has no CreatorProfile OR dmActive: false
400message.send.error.vacationCreator has vacationMode: true
400message.send.error.dm_type_mismatchSubmitted dmType doesn't match creator's configured dmType
400message.send.error.duplicateSame senderId + receiverId + searchContent within messaging.duplicate_window_seconds (default 60s)
400message.send.error.free_dm_daily_limitdm.free_daily_limit exhausted (UTC day)
400message.send.error.free_dm_per_creator_limitdm.free_per_creator_daily exhausted for this creator (UTC day)
400message.send.error.service_unavailableRedis cache offline (free DMs only โ€” security-first reject)
400message.send.error.price_below_minimumSubmitted price < activeDMPackage.price
400message.send.error.pending_paid_existsAlready have a PENDING/ESCROWED paid DM to this same creator
400payment.escrow.wallet_unavailableNo FAN wallet OR frozen: true (paid DMs)
400payment.escrow.insufficient_balancewallet.balance < price (paid DMs)
401(guard)Missing / invalid bearer token
403message.send.error.email_not_verifiedSender hasn't verified email
403message.send.error.blockedReceiver has blocked sender (BlockList check)
429(throttle)Rate limit exceeded (10 req/min)
503features.messaging_disabledAdmin kill switch MESSAGING is active

Side effects

  1. Self-message guard + content non-empty.
  2. Sender email verification gate (404/403 if missing or unverified).
  3. Receiver gate โ€” must exist AND User.status = ACTIVE.
  4. Block check โ€” BlockList { ownerId: receiverId, blockedId: senderId }.
  5. Creator DM config โ€” CreatorProfile.dmActive AND not vacationMode AND dmType matches.
  6. Duplicate window โ€” searchContent projection match within messaging.duplicate_window_seconds.
  7. Free DM rate limits (when dmType = FREE) โ€” Redis INCR with TTL on free_dm:<senderId>:<utcDate> AND free_dm:<senderId>:<receiverId>:<utcDate>. Rollback DECR on over-limit. Reject if Redis offline.
  8. Get-or-create ChatSession for the (fan, creator) pair (bidirectional match).
  9. Resolve timeoutHours โ€” body or dm.timeout_hours default.
  10. Paid DM extra checks (when dmType !== 'FREE' && price > 0):
    • price >= activeDMPackage.price (Decimal comparison).
    • Pending paid concurrency cap โ€” count of PENDING/ESCROWED paid DMs from sender to receiver must be 0.
    • Pre-escrow balance check โ€” wallet exists, not frozen, balance >= price.
    • Resolve commissionRate from creator.commission_<level> config (per creator level).
  11. Atomic create: insert Message (encrypted content, plaintext searchContent, priceSnapshot, commissionRate, expiresAt).
  12. Create escrow for paid DMs via escrowService.createEscrow โ€” on failure, delete the orphan message + new chat session for recovery.
  13. Snapshot โ€” MessageRuleSnapshot.create (fire-and-forget โ€” failure logs but doesn't block).
  14. Moderation โ€” moderateContent(content) against active ModerationRule rows (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).
  15. Status transition (no flag) โ€” paid โ†’ ESCROWED, free โ†’ DELIVERED.
  16. Notify creator via notificationService.send({ eventKey: 'new_message_creator', deepLink: '/messages/<sessionId>' }) (fire-and-forget; failure logged).
  17. Update ChatSession.lastMessageAt for conversation ordering.
  18. Increment messageSentTotal Prometheus counter.
  19. 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

POST
/api/v1/messages
AuthorizationBearer <token>

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

SourcePathLines
Controllerapps/api-core/src/modules/message/message.controller.ts34โ€“54 (sendMessage)
DTO (request)apps/api-core/src/modules/message/dto/index.ts5โ€“21 (SendMessageDto)
DTO (response)apps/api-core/src/modules/message/dto/message-client-response.dto.ts8โ€“14 (SendMessageResponseDto)
Serviceapps/api-core/src/modules/message/message.service.ts93โ€“348 (sendMessage), 541โ€“584 (enforceFreeDmLimits), 488โ€“528 (moderateContent), 360โ€“419 (transition + getValidSourceStatuses)
Escrowapps/api-core/src/modules/payment/escrow.service.tscreateEscrow()
Encryptionapps/api-core/src/common/encryption.tsencryptContent()
Notification pipelineapps/api-core/src/modules/notification/notification.service.tssend({ eventKey: 'new_message_creator' })
Config keysapps/api-core/src/modules/config/config.service.tsdm.timeout_hours, dm.free_daily_limit, dm.free_per_creator_daily, messaging.duplicate_window_seconds, creator.commission_<level> (admin-managed)
Prisma modelspackages/prisma/prisma/schema.prismaMessage, ChatSession, Wallet (FAN, escrow check), BlockList, CreatorProfile.dm*, DMPackage.price, MessageRuleSnapshot, Escrow, ModerationRule, MessageStatusLog

On this page