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
| Param | Type | Validation | Notes |
|---|---|---|---|
id | string | (no UUID pipe) | The original message id (the one being replied to) |
Body — ReplyMessageDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
content | string | ✓ | MinLength(1), MaxLength(5000) | Reply body. Higher cap than send (5000 vs 2000) — creators can be more verbose. Encrypted at rest. |
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | JWT from POST /auth/login |
Response
200 OK — SuccessOnlyResponseDto
{
"success": true
}| Field | Type | Notes |
|---|---|---|
success | boolean | Always 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
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | message.reply.error.invalid_status | Status 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 |
403 | message.reply.error.not_authorized | You're not the message's receiverId |
404 | message.reply.error.not_found | No Message with this id |
429 | (throttle) | Rate limit exceeded (20 req/min) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
- Lookup + ownership — load
Message; thrownot_foundif missing; thrownot_authorizedifreceiverId !== userId. - Status guard — must be
ESCROWEDorDELIVERED; otherwiseinvalid_status(carries{ status }). - Atomic claim —
updateMany({ id, status: <currentStatus> }, data: { status: REPLIED, repliedAt: now() }).count = 0→ already-processed-by-other-operation (race with expiry / reject). Throwinvalid_status. - Create reply message —
Message.create({ senderId: creator, receiverId: original-sender, dmType: <inherited>, content: encrypted, status: COMPLETED }). (The reply itself starts atCOMPLETED— there's no escrow on the reply direction.) - Paid DM path — release escrow via
escrowService.releaseEscrow(messageId, creatorUserId). On success: transition original messageREPLIED → COMPLETED(withcompletedAt = now()). On failure (CRITICAL log): revert original to its prior status withrepliedAt = nullAND delete the orphan reply message (5s window check). Throw the original error. - Free DM path — skip escrow; transition original
REPLIED → COMPLETEDdirectly. - Update
ChatSession.lastMessageAtfor conversation ordering. - Increment
messageRepliedTotalPrometheus counter. - 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
Authorization
bearer In: header
Path Parameters
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/message/message.controller.ts | 162–178 (replyToMessage) |
| DTO (request) | apps/api-core/src/modules/message/dto/index.ts | 23–26 (ReplyMessageDto) |
| DTO (response) | apps/api-core/src/common/dto/common-response.dto.ts | SuccessOnlyResponseDto |
| Service | apps/api-core/src/modules/message/message.service.ts | 590–671 (replyToMessage), 360–419 (transition + getValidSourceStatuses) |
| Escrow | apps/api-core/src/modules/payment/escrow.service.ts | releaseEscrow() |
| Encryption | apps/api-core/src/common/encryption.ts | encryptContent() |
| Notification pipeline | apps/api-core/src/modules/notification/notification.service.ts | send({ eventKey: 'message_replied_fan' }) |
| Prisma models | packages/prisma/prisma/schema.prisma | Message (enum MessageStatus), ChatSession.lastMessageAt, Wallet (creator credit via escrow) |
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).
Reject Message
Creator-only rejection. Requires ESCROWED or DELIVERED status. Triggers full refund to fan's wallet for paid DMs (atomic claim + escrow refund). Optional reason captured for audit + notification.