BIO.RE
Support

Reply to Ticket

Add a user reply to your ticket. Auto-transitions WAITING_USER → IN_PROGRESS. Rejects when the ticket is CLOSED. Sends a ticket_update notification to the assigned agent (if any).

POST /api/v1/tickets/:ticketId/reply — 🔑 Bearer · 204 200 OK (success-only envelope)

Adds a user-authored message to the ticket. If the ticket was waiting for the user (WAITING_USER), it flips to IN_PROGRESS so the agent's queue picks it up. Otherwise the status doesn't change.

isInternal in the request body is ignored for users. The DTO accepts the field, but the user-facing controller hard-codes 'USER' as the authorType and the service path for users never reads isInternal — the message lands with isInternal: false. (The same DTO is reused by the admin agent-reply endpoint, which DOES respect it.) Don't ship isInternal: true in client code expecting it to do anything.

Foreign tickets respond 403, not 404. Unlike the detail endpoint, the reply path runs the lookup first and then checks ownership separately — non-owners get support.ticket.not_owner (403). Combined with detail-endpoint behavior, a malicious caller could in theory probe by attempting a reply to a guessed ticketId — but they'd still need to send valid auth and a 1-5000 char body, so it's a noisy attack. Worth knowing for monitoring rules.

Notification to the agent is fire-and-forget. After the message is committed, the service sends a ticket_update notification to ticket.assignedTo (if set) only when isInternal === false. For user replies, isInternal is always false, so the notification always fires when an agent is assigned.

Request

Path parameters

ParamTypeRequiredValidationNotes
ticketIdstring (UUID)ParseUUIDPipeYour ticket.

Body — ReplyTicketDto

FieldTypeRequiredValidationNotes
contentstringMinLength(1) + MaxLength(5000)Plain text. No HTML sanitization at this layer.
isInternalbooleanoptionalIsBooleanIgnored on the user path — see warn callout.

Headers

HeaderRequiredNotes
Authorization: Bearer <accessToken>Global JwtAuthGuard.
Content-Type: application/jsonStandard.

Response

200 OKSuccessOnlyResponseDto

{ "success": true }

The controller hard-codes @HttpCode(200) so the framework's default 201 for POST is overridden. The body is the platform's success-only envelope — no payload, no message id returned. To see the message in context, fetch GET /tickets/:ticketId again.

Status transition

The status change happens inside the same $transaction as the new message:

Ticket wasAfter your replyWhy
WAITING_USERIN_PROGRESSUser responded — back into the agent queue.
Anything else (OPEN, ASSIGNED, IN_PROGRESS, WAITING_INTERNAL, RESOLVED)unchangedService doesn't pull the ticket out of these states on a user reply.
CLOSEDn/a — request rejected with 400See errors.

Errors

HTTPCode / i18nKeyReason
400(ParseUUIDPipe):ticketId is not a UUID.
400(DTO validation)content length out of range, missing field.
400support.ticket.closedTicket is in CLOSED status — reopen it first.
401(guard)Missing / invalid bearer token.
403support.ticket.not_ownerThe ticket exists but belongs to another user.
404support.ticket.not_foundTicket does not exist.

Side effects

  1. Decode bearer; ParseUUIDPipe validates :ticketId.
  2. prisma.ticket.findUnique({ where: { id } }). If missing → throw 404.
  3. If status === 'CLOSED' → throw 400 support.ticket.closed.
  4. Owner check: if (ticket.userId !== userId) throw 403 support.ticket.not_owner.
  5. Compute newStatus: was WAITING_USERIN_PROGRESS; otherwise unchanged.
  6. Atomic write via prisma.$transaction([...]):
    • Create TicketMessage row: { id: randomUUID(), ticketId, authorId: userId, authorType: 'USER', content, isInternal: false }.
    • Update Ticket.status to newStatus (no-op when unchanged).
  7. Fire-and-forget notification to the assigned agent (when ticket.assignedTo is set): notificationService.send({ eventKey: 'ticket_update', userId: ticket.assignedTo, variables: { ticketId } }) — failures logged only.
  8. Return { success: true }.

Code samples

curl -X POST https://api.bio.re/api/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890/reply \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{ "content": "Yes, the IBAN is the same as the one on file. Please retry." }'
async function replyToTicket(
  accessToken: string,
  ticketId: string,
  content: string,
): Promise<void> {
  const res = await fetch(
    `https://api.bio.re/api/v1/tickets/${ticketId}/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,
    });
  }
}
import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useReplyToTicket(ticketId: string) {
  const qc = useQueryClient();
  return useMutation({
    mutationFn: async (content: string) => {
      const res = await fetch(`/api/v1/tickets/${ticketId}/reply`, {
        method: 'POST',
        headers: { '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,
        });
      }
    },
    onSuccess: () => {
      // Pull the new message + status from the server.
      qc.invalidateQueries({ queryKey: ['support', 'ticket', ticketId] });
      // Status may have flipped (WAITING_USER → IN_PROGRESS), so refresh the list too.
      qc.invalidateQueries({ queryKey: ['support', 'tickets'] });
    },
  });
}

Try it

POST
/api/v1/tickets/{ticketId}/reply
AuthorizationBearer <token>

In: header

Path Parameters

ticketId*string

Request Body

application/json

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

curl -X POST "https://loading/api/v1/tickets/string/reply" \  -H "Content-Type: application/json" \  -d '{    "content": "We are looking into this issue."  }'
{
  "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"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/support/support.controller.ts68–78 (reply — hard-codes 'USER' authorType)
DTO (request)apps/api-core/src/modules/support/dto/index.ts19–25 (ReplyTicketDto)
Serviceapps/api-core/src/modules/support/support.service.ts76–100 (replyToTicket — close check, owner check, status transition, notification)
Prisma modelspackages/prisma/prisma/schema.prismaTicket lines 1245–1266, TicketMessage lines 1268–1279

On this page