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
| Param | Type | Required | Validation | Notes |
|---|---|---|---|---|
ticketId | string (UUID) | ✓ | ParseUUIDPipe | Your ticket. |
Body — ReplyTicketDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
content | string | ✓ | MinLength(1) + MaxLength(5000) | Plain text. No HTML sanitization at this layer. |
isInternal | boolean | optional | IsBoolean | Ignored on the user path — see warn callout. |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard. |
Content-Type: application/json | ✓ | Standard. |
Response
200 OK — SuccessOnlyResponseDto
{ "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 was | After your reply | Why |
|---|---|---|
WAITING_USER | IN_PROGRESS | User responded — back into the agent queue. |
Anything else (OPEN, ASSIGNED, IN_PROGRESS, WAITING_INTERNAL, RESOLVED) | unchanged | Service doesn't pull the ticket out of these states on a user reply. |
CLOSED | n/a — request rejected with 400 | See errors. |
Errors
| HTTP | Code / i18nKey | Reason |
|---|---|---|
400 | (ParseUUIDPipe) | :ticketId is not a UUID. |
400 | (DTO validation) | content length out of range, missing field. |
400 | support.ticket.closed | Ticket is in CLOSED status — reopen it first. |
401 | (guard) | Missing / invalid bearer token. |
403 | support.ticket.not_owner | The ticket exists but belongs to another user. |
404 | support.ticket.not_found | Ticket does not exist. |
Side effects
- Decode bearer;
ParseUUIDPipevalidates:ticketId. prisma.ticket.findUnique({ where: { id } }). If missing → throw404.- If
status === 'CLOSED'→ throw400 support.ticket.closed. - Owner check:
if (ticket.userId !== userId) throw 403 support.ticket.not_owner. - Compute
newStatus: wasWAITING_USER→IN_PROGRESS; otherwise unchanged. - Atomic write via
prisma.$transaction([...]):- Create
TicketMessagerow:{ id: randomUUID(), ticketId, authorId: userId, authorType: 'USER', content, isInternal: false }. - Update
Ticket.statustonewStatus(no-op when unchanged).
- Create
- Fire-and-forget notification to the assigned agent (when
ticket.assignedTois set):notificationService.send({ eventKey: 'ticket_update', userId: ticket.assignedTo, variables: { ticketId } })— failures logged only. - 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
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
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/support/support.controller.ts | 68–78 (reply — hard-codes 'USER' authorType) |
| DTO (request) | apps/api-core/src/modules/support/dto/index.ts | 19–25 (ReplyTicketDto) |
| Service | apps/api-core/src/modules/support/support.service.ts | 76–100 (replyToTicket — close check, owner check, status transition, notification) |
| Prisma models | packages/prisma/prisma/schema.prisma | Ticket lines 1245–1266, TicketMessage lines 1268–1279 |
Get Ticket Detail
Single ticket including its full message history and category. Owner-scoped — querying someone else's ticket returns 404 (not 403) so existence is never leaked.
Reopen Ticket
Move a RESOLVED or CLOSED ticket back to OPEN. Clears resolvedAt + closedAt. Owner-only. Fails if the ticket is in any other status.