BIO.RE
Messages

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.

POST /api/v1/messages/:id/reply — 🔑 Bearer · Rate limit: 20 req / minute · Kill-switched

Creator's reply to a fan's message. Requires status ESCROWED (paid DMs) or DELIVERED (free DMs). Atomic claim prevents race with the expiry cron job. For paid DMs, escrow is released to the creator's wallet (after commission). On escrow-release failure, the message status is reverted and the orphan reply row is deleted (compensation).

Status state machine: ESCROWED|DELIVERED → REPLIED → COMPLETED. The intermediate REPLIED state exists so escrow release happens AFTER the row is "claimed" — guaranteeing exactly one reply even when the expiry cron is racing.

Compensation on escrow-release failure: if the escrow service throws, the server attempts to revert the message back to its original status (and clear repliedAt) AND delete the orphan reply message that was just created. If the revert also fails, the row is left in REPLIED status with no completion — manual reconciliation is required (operations alerts on [msg] CRITICAL log lines).

Request

Path parameters

ParamTypeValidationNotes
idstring(no UUID pipe)The original message id (the one being replied to)

Body — ReplyMessageDto

FieldTypeRequiredValidationNotes
contentstringMinLength(1), MaxLength(5000)Reply body. Higher cap than send (5000 vs 2000) — creators can be more verbose. Encrypted at rest.
HeaderRequiredNotes
Authorization: Bearer <accessToken>JWT from POST /auth/login

Response

200 OKSuccessOnlyResponseDto

{
  "success": true
}
FieldTypeNotes
successbooleanAlways true on 200. Refetch the message detail or conversation list to see the new state (COMPLETED for paid, with creator wallet credited; or directly COMPLETED for free).

Errors

HTTPcode / i18nKeyReason
400message.reply.error.invalid_statusStatus is not ESCROWED or DELIVERED. Payload includes { status } so the UI can render the actual state.
400(DTO validation)Content empty / over 5000 chars
401(guard)Missing / invalid bearer token
403message.reply.error.not_authorizedYou're not the message's receiverId
404message.reply.error.not_foundNo Message with this id
429(throttle)Rate limit exceeded (20 req/min)
503features.messaging_disabledAdmin kill switch MESSAGING is active

Side effects

  1. Lookup + ownership — load Message; throw not_found if missing; throw not_authorized if receiverId !== userId.
  2. Status guard — must be ESCROWED or DELIVERED; otherwise invalid_status (carries { status }).
  3. Atomic claimupdateMany({ id, status: <currentStatus> }, data: { status: REPLIED, repliedAt: now() }). count = 0 → already-processed-by-other-operation (race with expiry / reject). Throw invalid_status.
  4. Create reply messageMessage.create({ senderId: creator, receiverId: original-sender, dmType: <inherited>, content: encrypted, status: COMPLETED }). (The reply itself starts at COMPLETED — there's no escrow on the reply direction.)
  5. Paid DM path — release escrow via escrowService.releaseEscrow(messageId, creatorUserId). On success: transition original message REPLIED → COMPLETED (with completedAt = now()). On failure (CRITICAL log): revert original to its prior status with repliedAt = null AND delete the orphan reply message (5s window check). Throw the original error.
  6. Free DM path — skip escrow; transition original REPLIED → COMPLETED directly.
  7. Update ChatSession.lastMessageAt for conversation ordering.
  8. Increment messageRepliedTotal Prometheus counter.
  9. Notify fan via notificationService.send({ eventKey: 'message_replied_fan', deepLink: '/messages/<sessionId>' }) (fire-and-forget).

Code samples

curl -X POST https://api.bio.re/api/v1/messages/m1a2b3c4-d5e6-7890-abcd-ef1234567890/reply \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"content": "Thanks for reaching out!"}'
async function replyToMessage(accessToken: string, messageId: string, content: string): Promise<void> {
  const res = await fetch(`https://api.bio.re/api/v1/messages/${messageId}/reply`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ content }),
  });
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? 'Reply failed'), {
      code: json?.error?.code,
      status: json?.error?.status, // present on 'invalid_status'
    });
  }
}
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useReplyToMessage() {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (vars: { messageId: string; content: string }) => {
      const res = await fetch(`/api/v1/messages/${vars.messageId}/reply`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ content: vars.content }),
      });
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Reply failed'), {
          code: json?.error?.code,
          i18nKey: json?.error?.i18nKey,
          status: json?.error?.status,
        });
      }
    },
    onSuccess: () => {
      // Multi-resource invalidation: message detail + thread inbox + conversations + unread + creator wallet
      qc.invalidateQueries({ queryKey: ['messages'] });
      qc.invalidateQueries({ queryKey: ['wallet', 'balance'] });
      qc.invalidateQueries({ queryKey: ['wallet', 'activity'] });
    },
  });
}

Try it

POST
/api/v1/messages/{id}/reply
AuthorizationBearer <token>

In: header

Path Parameters

id*string

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/string/reply" \  -H "Content-Type: application/json" \  -d '{    "content": "Thanks for reaching out!"  }'
{
  "success": true
}
{
  "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.ts162–178 (replyToMessage)
DTO (request)apps/api-core/src/modules/message/dto/index.ts23–26 (ReplyMessageDto)
DTO (response)apps/api-core/src/common/dto/common-response.dto.tsSuccessOnlyResponseDto
Serviceapps/api-core/src/modules/message/message.service.ts590–671 (replyToMessage), 360–419 (transition + getValidSourceStatuses)
Escrowapps/api-core/src/modules/payment/escrow.service.tsreleaseEscrow()
Encryptionapps/api-core/src/common/encryption.tsencryptContent()
Notification pipelineapps/api-core/src/modules/notification/notification.service.tssend({ eventKey: 'message_replied_fan' })
Prisma modelspackages/prisma/prisma/schema.prismaMessage (enum MessageStatus), ChatSession.lastMessageAt, Wallet (creator credit via escrow)

On this page