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.
POST /api/v1/tickets โ ๐ Bearer ยท Rate limit: 5 req / minute
Creates a new support ticket owned by the current user. The first message body is created in the same DB transaction, so the ticket starts with exactly one user message and is ready for an agent to pick up.
Single-shot atomic write. The ticket row and its first TicketMessage are created in one Prisma $transaction([...]). Either both rows land or neither does โ there's no half-created state where the ticket exists but has no body.
Priority resolution order: explicit priority in body > category's default priority (if categoryId is set) > MEDIUM. The category is fetched only when categoryId is provided; if it doesn't exist, the request fails with support.category.not_found (404) before any ticket is created.
Notification is fire-and-forget. After the ticket is committed, the service calls notificationService.send({ eventKey: 'ticket_created', ... }) and does not await the result โ failures are logged but never bubble up. A 201 means the ticket was created; it does NOT mean the user has been notified yet.
Request
Body โ CreateTicketDto
| Field | Type | Required | Validation | Notes |
|---|---|---|---|---|
subject | string | โ | MinLength(3) + MaxLength(200) | Short title โ surface in the inbox row. |
content | string | โ | MinLength(10) + MaxLength(5000) | First message body. Plain text โ no HTML sanitization at this layer. |
categoryId | string (UUID) | optional | IsString | Pre-existing category from the admin tool. If supplied and not found โ 404. |
priority | enum | optional | IsEnum(Priority) โ LOW / MEDIUM / HIGH / URGENT | Overrides the category default. Omit to inherit from the category, or fall through to MEDIUM. |
Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <accessToken> | โ | Global JwtAuthGuard. |
Content-Type: application/json | โ | Standard. |
Response
201 Created โ ApiResponseOf<CreateTicketResponseDto>
{
"success": true,
"data": {
"ticketId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
}| Field | Type | Notes |
|---|---|---|
ticketId | string (UUID) | Server-generated randomUUID(). Use it as :ticketId for detail, reply, reopen. |
The ticket starts at status: 'OPEN' with the resolved priority. The first message is created with authorType: 'USER' and isInternal: false.
Errors
| HTTP | Code / i18nKey | Reason |
|---|---|---|
400 | (DTO validation) | subject/content length out of range, missing fields, unknown enum value for priority. |
401 | (guard) | Missing / invalid bearer token. |
404 | support.category.not_found | categoryId was supplied but the row doesn't exist. No ticket is created. |
429 | THROTTLE_LIMIT_EXCEEDED | More than 5 requests / minute from this IP. Response carries retryAfter (seconds). |
Side effects
- Decode bearer (global
JwtAuthGuard). ThrottleGuardincrementsthrottle:<ip>:POST:/api/v1/ticketsin Redis cache; throws429if count > 5 in 60s window. Falls back to in-process Map if Redis cache is down.- If
categoryIdis set:prisma.ticketCategory.findUnique({ where: { id }, select: { id, priority } }). Missing โ throwI18nNotFound('support.category.not_found')(no rows written). - Resolve
priority:body.priority ?? cat.priority ?? Priority.MEDIUM. - Generate
ticketId = randomUUID(). - Atomic write via
prisma.$transaction([...]):- Create
Ticketrow:{ id: ticketId, userId, subject, status: 'OPEN', priority, categoryId }. - Create
TicketMessagerow:{ id: randomUUID(), ticketId, authorId: userId, authorType: 'USER', content, isInternal: false }.
- Create
- Fire-and-forget notification:
notificationService.send({ eventKey: 'ticket_created', userId, variables: { ticketId } })โ.catch(...)logs errors only. - Log
[support] Ticket created: <id> (priority: <p>)and return{ ticketId }.
Code samples
curl -X POST https://api.bio.re/api/v1/tickets \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"subject": "Payout delayed by 3 days",
"content": "I requested a payout on 2026-04-20 but I have not received the funds yet.",
"categoryId": "11111111-2222-3333-4444-555555555555"
}'type CreateTicketBody = {
subject: string; // 3-200 chars
content: string; // 10-5000 chars
categoryId?: string;
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
};
async function createTicket(
accessToken: string,
body: CreateTicketBody,
): Promise<{ ticketId: string }> {
const res = await fetch('https://api.bio.re/api/v1/tickets', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Create failed'), {
code: json?.error?.code,
retryAfter: json?.retryAfter, // present on 429
});
}
return json.data;
}import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useCreateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: CreateTicketBody) => {
const res = await fetch('/api/v1/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!res.ok || !json.success) {
throw Object.assign(new Error(json?.error?.message ?? 'Create failed'), {
code: json?.error?.code,
});
}
return json.data as { ticketId: string };
},
onSuccess: () => {
// The new ticket is OPEN โ invalidate the ticket list so it re-fetches.
qc.invalidateQueries({ queryKey: ['support', 'tickets'] });
},
});
}Try it
Authorization
bearer In: header
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" \ -H "Content-Type: application/json" \ -d '{ "subject": "Payment not received", "content": "I sent a message 3 days ago but the creator has not responded and my payment is still held." }'{
"success": true,
"data": {
"ticketId": "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/support/support.controller.ts | 48โ57 (create, @Throttle(5, 60)) |
| DTO (request) | apps/api-core/src/modules/support/dto/index.ts | 5โ17 (CreateTicketDto) |
| DTO (response) | apps/api-core/src/modules/support/dto/support-response.dto.ts | 217โ220 (CreateTicketResponseDto) |
| Service | apps/api-core/src/modules/support/support.service.ts | 52โ74 (createTicket โ category lookup, priority resolution, $transaction, notification) |
| Throttle | apps/api-core/src/common/guards/throttle.guard.ts | 64โ79 (Redis), 81โ101 (in-memory fallback) |
| Prisma models | packages/prisma/prisma/schema.prisma | Ticket lines 1245โ1266, TicketMessage lines 1268โ1279, TicketCategory lines 1281โ1292, Priority enum 819โ824 |
List My Tickets
Owner-scoped paginated list of the current user's support tickets. Filter by status. Always returns YOUR tickets โ no admin lens, no cross-user reads possible.
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.