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.
GET /api/v1/tickets/:ticketId — 🔑 Bearer
Returns the ticket plus its full ordered message history and the embedded category. Owned by current user only — see the security note below.
Foreign tickets respond 404, not 403. The service does if (!ticket || ticket.userId !== userId) throw I18nNotFound(...). This collapses two cases into one response so a caller can't probe for existence by trying random UUIDs. Don't try to distinguish "not found" from "not yours" client-side — they're indistinguishable on the wire by design.
Internal messages are NOT filtered out here. The service does include: { messages: { orderBy: { createdAt: 'asc' } } } — every TicketMessage row is returned, including those with isInternal: true. As of writing, the user-side controller doesn't strip them. Hide rows where isInternal === true client-side when rendering the user-facing thread, otherwise admin-only notes will leak into the user's UI.
No throttle on this read. Same as the list — only the ticket-create handler has @Throttle(). Polling is fine.
Request
Path parameters
| Param | Type | Required | Validation | Notes |
|---|---|---|---|---|
ticketId | string (UUID) | ✓ | ParseUUIDPipe (rejects non-UUID with 400) | The ticket id you got from create or list. |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | ✓ | Global JwtAuthGuard. |
Response
200 OK — ApiResponseOf<TicketDetailDto>
{
"success": true,
"data": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"userId": "00000000-0000-4000-8000-000000000001",
"categoryId": "11111111-2222-3333-4444-555555555555",
"subject": "Payout delayed by 3 days",
"status": "WAITING_USER",
"priority": "HIGH",
"assignedTo": "agent-uuid",
"resolvedAt": null,
"closedAt": null,
"createdAt": "2026-04-20T09:00:00.000Z",
"updatedAt": "2026-04-22T14:30:00.000Z",
"category": {
"id": "11111111-2222-3333-4444-555555555555",
"name": "Payments",
"description": "Payment-related issues",
"priority": "HIGH",
"active": true,
"sortOrder": 1,
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
},
"messages": [
{
"id": "msg-uuid-1",
"ticketId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"authorId": "00000000-0000-4000-8000-000000000001",
"authorType": "USER",
"content": "I requested a payout on 2026-04-20 but I have not received the funds yet.",
"isInternal": false,
"createdAt": "2026-04-20T09:00:00.000Z"
},
{
"id": "msg-uuid-2",
"ticketId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"authorId": "agent-uuid",
"authorType": "AGENT",
"content": "Looking into this. Can you confirm the bank account on file?",
"isInternal": false,
"createdAt": "2026-04-22T14:30:00.000Z"
}
]
}
}Top-level fields
Same as the list item plus:
| Field | Type | Notes |
|---|---|---|
messages | TicketMessageEmbedDto[] | Full message history sorted by createdAt ASC (oldest first). Includes internal notes — filter client-side. |
Message fields (messages[])
| Field | Type | Notes |
|---|---|---|
id | string (UUID) | Message id. No public endpoint to fetch a single message — always read via the parent ticket. |
ticketId | string (UUID) | Echo of the path parameter. |
authorId | string (UUID) | The user (USER) or agent (AGENT) who wrote this message. |
authorType | enum | USER or AGENT. |
content | string | Plain text — no HTML sanitization at this layer. Render with whiteSpace: pre-wrap for line breaks. |
isInternal | boolean | Hide rows where this is true in the user-facing UI. |
createdAt | string (ISO 8601) | When the message was created. |
Errors
| HTTP | Code / i18nKey | Reason |
|---|---|---|
400 | (ParseUUIDPipe) | :ticketId is not a UUID. |
401 | (guard) | Missing / invalid bearer token. |
404 | support.ticket.not_found | Ticket doesn't exist OR belongs to another user. Cases are intentionally indistinguishable. |
Side effects
- Decode bearer;
ParseUUIDPipevalidates:ticketId. prisma.ticket.findUnique({ where: { id }, include: { messages: { orderBy: { createdAt: 'asc' } }, category: true } }).- Owner check + missing collapsed:
if (!ticket || ticket.userId !== userId) throw I18nNotFound. - Return the ticket as-is. No DB writes, no notifications.
Code samples
curl https://api.bio.re/api/v1/tickets/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
-H "Authorization: Bearer $ACCESS_TOKEN"type TicketMessage = {
id: string;
ticketId: string;
authorId: string;
authorType: 'USER' | 'AGENT';
content: string;
isInternal: boolean;
createdAt: string;
};
type TicketDetail = TicketListItem & { messages: TicketMessage[] };
async function getTicket(
accessToken: string,
ticketId: string,
): Promise<TicketDetail> {
const res = await fetch(
`https://api.bio.re/api/v1/tickets/${ticketId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Fetch failed'), {
code: json?.error?.code,
});
}
return json.data;
}
// In your render layer — strip internal notes before showing to the user:
const visible = (await getTicket(token, id)).messages.filter(m => !m.isInternal);import { useQuery } from '@tanstack/react-query';
export function useTicketDetail(ticketId: string) {
return useQuery({
queryKey: ['support', 'ticket', ticketId],
queryFn: async () => {
const res = await fetch(`/api/v1/tickets/${ticketId}`);
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Fetch failed'), {
code: json?.error?.code,
});
}
return json.data as TicketDetail;
},
enabled: !!ticketId,
// Refetch every 30s while the user is on this page; pair with a "send reply"
// mutation that invalidates this query for instant updates after they post.
refetchInterval: 30_000,
staleTime: 0,
});
}Try it
Authorization
bearer In: header
Path Parameters
Response Body
application/json
application/json
application/json
curl -X GET "https://loading/api/v1/tickets/string"{
"success": true,
"data": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"userId": "2c4a230c-5085-4924-a3e1-25fb4fc5965b",
"categoryId": "337f5e5d-288b-40d5-be14-901cc3acacc0",
"subject": "Payout delayed by 3 days",
"status": "OPEN",
"priority": "LOW",
"assignedTo": "7869c1a9-a680-4086-b6f5-9e78a651f6f2",
"resolvedAt": "2019-08-24T14:15:22Z",
"closedAt": "2019-08-24T14:15:22Z",
"createdAt": "2019-08-24T14:15:22Z",
"updatedAt": "2019-08-24T14:15:22Z",
"category": {
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "Billing & Payments",
"description": "string",
"priority": "LOW",
"active": true,
"sortOrder": 1,
"createdAt": "2019-08-24T14:15:22Z",
"updatedAt": "2019-08-24T14:15:22Z"
},
"messages": [
{
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"ticketId": "e3e3e8ea-b02a-4536-85a2-a9c90f4ee74f",
"authorId": "ee6f7132-bd0a-4fcd-83b3-a8022377067b",
"authorType": "USER",
"content": "Details about: Payout delayed by 3 days",
"isInternal": true,
"createdAt": "2019-08-24T14:15:22Z"
}
]
}
}{
"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 | 59–66 (get) |
| Service | apps/api-core/src/modules/support/support.service.ts | 102–109 (getTicket — owner check collapses 404 + 403) |
| DTO (response) | apps/api-core/src/modules/support/dto/support-response.dto.ts | 114–117 (TicketDetailDto), 85–106 (TicketMessageEmbedDto) |
| Prisma models | packages/prisma/prisma/schema.prisma | Ticket lines 1245–1266, TicketMessage lines 1268–1279 |
Create Support Ticket
Open a new ticket as the current user. Returns just the new ticketId. Throttled to 5 per minute per IP. Optional category drives default priority. Sends a ticket_created notification fire-and-forget.
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).