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.
POST /api/v1/messages/:id/reject โ ๐ Bearer ยท Rate limit: 60 req / minute ยท Kill-switched
Creator declines to reply to a message. Atomically claims the row (ESCROWED|DELIVERED โ REJECTED) to prevent races with reply / expiry. For paid DMs, the escrow is refunded to the fan's wallet in full (no commission deducted on rejection); for free DMs, the message is simply marked refunded (no money moved). Optional reason is included in the fan's notification and the audit trail.
Reject = full refund. The fan gets priceSnapshot back to their wallet โ no platform commission, no creator earnings. The platform only earns on COMPLETED (creator replied within window).
Critical-failure path: if the escrow refund throws, the message stays at REJECTED (not advanced to REFUNDED) and the failure is logged at [msg] CRITICAL. Operations must reconcile the wallet state manually. The reject itself is committed โ there's no compensation revert here (because reject is the safe-for-fan direction; "still rejected, refund pending" is a recoverable state).
Request
Path parameters
| Param | Type | Validation | Notes |
|---|---|---|---|
id | string | (no UUID pipe) | The message being rejected |
Body โ RejectMessageDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
reason | string | optional | IsString() | Free-form rejection reason. Surfaces in the fan's "your message was declined" notification AND in the audit trail. Defaults to 'Creator declined' when absent. |
| 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. The fan's wallet balance refresh + notification dispatch happen out-of-band. |
Errors
| HTTP | code / i18nKey | Reason |
|---|---|---|
400 | message.reply.error.invalid_status | Status is not ESCROWED or DELIVERED. Payload includes { status }. |
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 (60 req/min โ controller default) |
503 | features.messaging_disabled | Admin kill switch MESSAGING is active |
Side effects
- Lookup + ownership โ load
Message;not_foundif missing;not_authorizedifreceiverId !== userId. - Status guard โ must be
ESCROWEDorDELIVERED; otherwiseinvalid_status. - Atomic claim โ
updateMany({ id, status: <currentStatus> }, data: { status: REJECTED }).count = 0โ race with reply/expiry, throwinvalid_status. - Paid DM refund:
escrowService.refundEscrow(messageId, fanUserId, reason ?? 'Creator rejected').- On success: transition
REJECTED โ REFUNDED. - On failure: log
[msg] CRITICAL(manual reconciliation needed). Status staysREJECTED. The reject itself is NOT reverted.
- Free DM: skip escrow refund; transition directly
REJECTED โ REFUNDED(no money moved). - Audit log:
[msg] Rejected + refunded: {messageId}. - Notify fan via
notificationService.send({ eventKey: 'message_rejected_refunded', variables: { creatorName, amount: '$<priceSnapshot>', reason } })(fire-and-forget).
Code samples
curl -X POST https://api.bio.re/api/v1/messages/m1a2b3c4-d5e6-7890-abcd-ef1234567890/reject \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"reason": "Not accepting questions on this topic right now"}'async function rejectMessage(accessToken: string, messageId: string, reason?: string): Promise<void> {
const res = await fetch(`https://api.bio.re/api/v1/messages/${messageId}/reject`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ reason }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Reject failed'), {
code: json?.error?.code,
status: json?.error?.status,
});
}
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useRejectMessage() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (vars: { messageId: string; reason?: string }) => {
const res = await fetch(`/api/v1/messages/${vars.messageId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: vars.reason }),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Reject failed'), {
code: json?.error?.code,
i18nKey: json?.error?.i18nKey,
status: json?.error?.status,
});
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['messages'] });
},
});
}Try it
Authorization
bearer In: header
Path Parameters
Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Rejection reason (optional)
Response Body
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://loading/api/v1/messages/string/reject" \ -H "Content-Type: application/json" \ -d '{}'{
"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 | 182โ197 (rejectMessage) |
| DTO (request) | apps/api-core/src/modules/message/dto/index.ts | 28โ31 (RejectMessageDto) |
| DTO (response) | apps/api-core/src/common/dto/common-response.dto.ts | SuccessOnlyResponseDto |
| Service | apps/api-core/src/modules/message/message.service.ts | 676โ711 (rejectMessage), 360โ419 (transition + getValidSourceStatuses) |
| Escrow | apps/api-core/src/modules/payment/escrow.service.ts | refundEscrow() |
| Notification pipeline | apps/api-core/src/modules/notification/notification.service.ts | send({ eventKey: 'message_rejected_refunded' }) |
| Prisma models | packages/prisma/prisma/schema.prisma | Message.status, Wallet (fan refund), Escrow (refund record) |
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.
Rate Message
Fan rates a completed conversation 1โ5 stars. Atomic create + recompute creator's avgRating / ratingCount in one transaction. Single-shot โ duplicate attempts are 409 (P2002).