BIO.RE
Messages

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

ParamTypeValidationNotes
idstring(no UUID pipe)The message being rejected

Body โ€” RejectMessageDto

FieldTypeRequiredValidationNotes
reasonstringoptionalIsString()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.
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login

Response

200 OK โ€” SuccessOnlyResponseDto

{
  "success": true
}
FieldTypeNotes
successbooleanAlways true on 200. The fan's wallet balance refresh + notification dispatch happen out-of-band.

Errors

HTTPcode / i18nKeyReason
400message.reply.error.invalid_statusStatus is not ESCROWED or DELIVERED. Payload includes { status }.
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 (60 req/min โ€” controller default)
503features.messaging_disabledAdmin kill switch MESSAGING is active

Side effects

  1. Lookup + ownership โ€” load Message; not_found if missing; not_authorized if receiverId !== userId.
  2. Status guard โ€” must be ESCROWED or DELIVERED; otherwise invalid_status.
  3. Atomic claim โ€” updateMany({ id, status: <currentStatus> }, data: { status: REJECTED }). count = 0 โ†’ race with reply/expiry, throw invalid_status.
  4. Paid DM refund:
    • escrowService.refundEscrow(messageId, fanUserId, reason ?? 'Creator rejected').
    • On success: transition REJECTED โ†’ REFUNDED.
    • On failure: log [msg] CRITICAL (manual reconciliation needed). Status stays REJECTED. The reject itself is NOT reverted.
  5. Free DM: skip escrow refund; transition directly REJECTED โ†’ REFUNDED (no money moved).
  6. Audit log: [msg] Rejected + refunded: {messageId}.
  7. 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

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

In: header

Path Parameters

id*string

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

reason?string

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

SourcePathLines
Controllerapps/api-core/src/modules/message/message.controller.ts182โ€“197 (rejectMessage)
DTO (request)apps/api-core/src/modules/message/dto/index.ts28โ€“31 (RejectMessageDto)
DTO (response)apps/api-core/src/common/dto/common-response.dto.tsSuccessOnlyResponseDto
Serviceapps/api-core/src/modules/message/message.service.ts676โ€“711 (rejectMessage), 360โ€“419 (transition + getValidSourceStatuses)
Escrowapps/api-core/src/modules/payment/escrow.service.tsrefundEscrow()
Notification pipelineapps/api-core/src/modules/notification/notification.service.tssend({ eventKey: 'message_rejected_refunded' })
Prisma modelspackages/prisma/prisma/schema.prismaMessage.status, Wallet (fan refund), Escrow (refund record)

On this page