Support tickets
A ticket is a threaded conversation between two sides: whoever raised it and whoever answers it. One set of endpoints serves all three support directions — a customer asking a shop, an employee asking their own admin, and a tenant asking the platform. Which direction a ticket travels is its scope, and scope alone decides who may read and answer it.
Unlike the rest of this reference, these endpoints accept any authenticated principal: staff, customers and super admins all use them. There is no role gate on the routes themselves. What a caller can see is decided entirely by the access rules below, applied to the principal — never by request input. A caller with no valid credentials gets 401.
For the product-level walkthrough, read Support.
Scopes and access
Every ticket carries a scope, a category, a status and a priority. All four are plain String columns whose allowed values live in schema comments and in the allowlists in src/lib/tickets.ts — there are no Prisma enums in this codebase. The values are:
| Field | Allowed values |
|---|---|
scope | CUSTOMER_TO_TENANT, STAFF_TO_TENANT, TENANT_TO_PLATFORM |
category | SUPPORT, COMPLAINT, SUGGESTION |
status | Open, In Progress, Resolved, Closed |
priority | Low, Normal, High, Urgent |
Every ticket has a tenantId, including TENANT_TO_PLATFORM, where it identifies the tenant doing the asking. No ticket query is ever unscoped.
Each principal is on exactly one side of a given ticket, and the same person can be on different sides of different tickets — a tenant admin is the agent on a customer's complaint but the requester on their own ticket to the platform. viewerSide on every serialized ticket tells you which.
Who answers, by scope:
| Scope | Agent side |
|---|---|
CUSTOMER_TO_TENANT | Any staff member of the owning tenant. |
STAFF_TO_TENANT | Only the owning tenant's admins. |
TENANT_TO_PLATFORM | Super admins only. |
Who sees what, per principal:
| Principal | Visible tickets |
|---|---|
| Customer | Only tickets they personally raised, in their own tenant. |
| Tenant employee | Their tenant's CUSTOMER_TO_TENANT tickets, plus STAFF_TO_TENANT tickets they raised themselves — not a colleague's. |
| Tenant admin | All three scopes within their own tenant. |
| Super admin | TENANT_TO_PLATFORM only, across every tenant. |
Super admins are deliberately not granted access to
CUSTOMER_TO_TENANTorSTAFF_TO_TENANTthreads. Those are a tenant's private conversations with its own customers and employees. Do not assume the platform can read them.
A tenant admin additionally co-owns every TENANT_TO_PLATFORM ticket their tenant raised, even one a colleague opened, so a thread does not become unreachable when its author leaves.
Which scopes a principal may open is narrower than what they may read:
| Principal | Can open |
|---|---|
| Customer | CUSTOMER_TO_TENANT |
| Tenant employee | STAFF_TO_TENANT |
| Tenant admin | TENANT_TO_PLATFORM |
| Super admin | Nothing. The platform answers; it does not file. |
An unreadable ticket returns 404, never 403, on every endpoint that takes an id. A 403 would confirm the id exists to someone probing for other tenants' tickets.
Customers authenticate on their tenant's subdomain, where API requests reach the handler directly and the customer token is validated there. On the main domain these endpoints require a staff session cookie or a Bearer token. See Authentication.
List tickets
GET /api/tickets
The ticket queue for whoever is asking, ordered by most recent activity, capped at 200.
The rows returned are decided by the principal. The filters below can only ever narrow that set — an invalid or hostile filter value is silently ignored rather than rejected, so it can never widen visibility.
| Name | Type | Required | Description |
|---|---|---|---|
scope | string | No | Narrow to one scope. Ignored unless it is a valid scope value. |
category | string | No | Narrow to one category. Ignored unless valid. |
status | string | No | Narrow to one status. Ignored unless valid. |
tenantId | string | No | Narrow to one tenant. Honoured only for super admins, ignored for everyone else — every other principal already has an implicit tenant. |
curl "https://goldpro.shop/api/tickets?status=Open" \
-H "Authorization: Bearer $TOKEN"
{
"tickets": [
{
"id": "cmf4tkt000001s601h3j5k7m9",
"reference": "TKT-K7M9",
"tenantId": "cmf1a2b3c0000s601e5f6g7h8",
"tenantName": "Sai Jewellers",
"tenantSlug": "sai-jewellers",
"scope": "CUSTOMER_TO_TENANT",
"category": "COMPLAINT",
"status": "Open",
"priority": "Normal",
"subject": "Certificate has the wrong net weight",
"requesterType": "customer",
"requesterId": "cmf2y8t4c0002s601p4h6b0qz",
"requesterName": "Anita Desai",
"assigneeId": null,
"assigneeName": null,
"lastMessageAt": "2026-07-16T08:12:40.552Z",
"createdAt": "2026-07-16T08:12:40.552Z",
"resolvedAt": null,
"closedAt": null,
"unread": true,
"viewerSide": "agent",
"canManage": true,
"canReply": true
}
],
"creatableScopes": ["TENANT_TO_PLATFORM"]
}
reference is a display number derived from the id — TKT- plus the last six characters upper-cased. It is not stored and not guaranteed unique; the id remains the key for every other call.
unread, viewerSide, canManage and canReply are computed for the principal this response was serialized for, not globally. The same ticket serialized for a different caller carries different values.
creatableScopes is what this principal may pass to Open a ticket. It is an empty array for super admins.
Open a ticket
POST /api/tickets
Creates a ticket and its opening message in one transaction, so a ticket can never exist without a message — the queue's activity sort and the thread view both assume at least one.
| Name | Type | Required | Description |
|---|---|---|---|
subject | string | Yes | Trimmed. Maximum 150 characters. |
message | string | Yes | The opening message. Trimmed. Maximum 5000 characters. |
scope | string | No | Must be one of your creatableScopes. Defaults to your only permitted scope, so simple clients can omit it. |
category | string | No | Defaults to SUPPORT. |
priority is not settable. It is accepted in the payload and ignored: everything starts at Normal and an agent triages it. A requester-set priority is a wish and would make the queue meaningless.
The tenant is taken from your principal. You cannot file a ticket into another tenant.
curl -X POST https://goldpro.shop/api/tickets \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"category": "SUPPORT",
"subject": "Gold rate per gram did not sync this morning",
"message": "The dashboard still shows yesterday'\''s gold rate per gram. Nothing has updated since 08:00 IST."
}'
Returns 201. The ticket object is the same shape as a list entry.
{
"success": true,
"ticket": {
"id": "cmf4tkt000002s601n2p4q6r8",
"reference": "TKT-Q6R8",
"tenantId": "cmf1a2b3c0000s601e5f6g7h8",
"tenantName": "Sai Jewellers",
"tenantSlug": "sai-jewellers",
"scope": "TENANT_TO_PLATFORM",
"category": "SUPPORT",
"status": "Open",
"priority": "Normal",
"subject": "Gold rate per gram did not sync this morning",
"requesterType": "staff",
"requesterId": "cmf1usr00001s601t2v4w6x8",
"requesterName": "Ravi Rohatgi",
"assigneeId": null,
"assigneeName": null,
"lastMessageAt": "2026-07-16T09:40:18.907Z",
"createdAt": "2026-07-16T09:40:18.907Z",
"resolvedAt": null,
"closedAt": null,
"unread": false,
"viewerSide": "requester",
"canManage": false,
"canReply": true
}
}
Every failure is a 400 carrying success: false and a message. A malformed JSON body is treated as an empty object, so it surfaces as a missing-subject error rather than a parse failure.
| Status | Body | Cause |
|---|---|---|
400 | {"success":false,"error":"You cannot raise a ticket of this type."} | scope is not one you may open. Super admins always land here — their creatable list is empty. |
400 | {"success":false,"error":"Unknown ticket category."} | category is not one of the three values. |
400 | {"success":false,"error":"A subject is required."} | Absent or whitespace-only. |
400 | {"success":false,"error":"Subject must be 150 characters or fewer."} | Over the limit. |
400 | {"success":false,"error":"A message is required."} | Absent or whitespace-only. |
400 | {"success":false,"error":"Message must be 5000 characters or fewer."} | Over the limit. |
401 | {"error":"Unauthorized"} | No valid principal. |
The requester's name is denormalized onto the ticket at creation rather than joined on read, so the queue still shows who asked after the account is gone. The opening message stamps the requester's own side as seen — by definition, its author has read it.
Read and triage a ticket
GET /api/tickets/{id}
Returns the ticket header. The thread itself is a separate call.
curl https://goldpro.shop/api/tickets/cmf4tkt000001s601h3j5k7m9 \
-H "Authorization: Bearer $TOKEN"
{ "ticket": { "id": "cmf4tkt000001s601h3j5k7m9", "reference": "TKT-K7M9", "status": "Open", "priority": "Normal" } }
The ticket object carries every field shown in List tickets; it is abridged above.
PATCH /api/tickets/{id}
Status, priority and assignment. Agent side only — a requester cannot mark their own complaint resolved, which is the point of a queue.
| Name | Type | Required | Description |
|---|---|---|---|
status | string | No | One of the four statuses. |
priority | string | No | One of the four priorities. |
assignToMe | boolean | No | Claim the ticket for yourself. |
unassign | boolean | No | Release it. Takes precedence over assignToMe if both are sent. |
Assignment is claim-and-release rather than assign-anyone; there is no way to assign a ticket to a third party.
curl -X PATCH https://goldpro.shop/api/tickets/cmf4tkt000001s601h3j5k7m9 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "status": "In Progress", "assignToMe": true }'
{
"success": true,
"ticket": {
"id": "cmf4tkt000001s601h3j5k7m9",
"status": "In Progress",
"priority": "Normal",
"assigneeId": "cmf1usr00001s601t2v4w6x8",
"assigneeName": "Ravi Rohatgi"
}
}
Timestamps are managed for you. Entering Resolved or Closed stamps resolvedAt; entering Closed also stamps closedAt. Moving to any other status clears both, so a reopened ticket does not keep claiming it was resolved.
Every accepted change also appends a system message to the thread describing what happened, so the requester sees the triage without a separate activity feed. A request that changes nothing — sending the status a ticket already has — is a 200 with the unchanged ticket and no system message.
A status change notifies the requester. A priority change or a claim does not; that is agent-side bookkeeping.
| Status | Body | Cause |
|---|---|---|
400 | {"success":false,"error":"Unknown ticket status."} | Not one of the four. |
400 | {"success":false,"error":"Unknown ticket priority."} | Not one of the four. |
401 | {"error":"Unauthorized"} | No valid principal — or you can read this ticket but are not on its agent side. |
404 | {"error":"Ticket not found"} | No such ticket, or you cannot read it. |
Note the 401 above: being the requester of a readable ticket and attempting to triage it returns 401, not 403.
Read the thread
GET /api/tickets/{id}/messages
The messages, oldest first, capped at 500. This is the polling endpoint — clients re-fetch it on a timer, so it is deliberately narrow and does no joins.
| Name | Type | Required | Description |
|---|---|---|---|
after | string | No | ISO 8601 timestamp. Returns only messages created strictly after it. An unparseable value is ignored and the full thread is returned. |
Poll with after set to the createdAt of the newest message you hold.
curl "https://goldpro.shop/api/tickets/cmf4tkt000001s601h3j5k7m9/messages?after=2026-07-16T08:12:40.552Z" \
-H "Authorization: Bearer $TOKEN"
{
"messages": [
{
"id": "cmf4msg000002s601b4c6d8e0",
"ticketId": "cmf4tkt000001s601h3j5k7m9",
"authorType": "staff",
"authorId": "cmf1usr00001s601t2v4w6x8",
"authorName": "Ravi Rohatgi",
"body": "Thanks for flagging this. Re-weighing the ornaments now.",
"internal": false,
"attachments": [],
"createdAt": "2026-07-16T08:31:19.204Z",
"mine": true
}
],
"status": "Open",
"canReply": true
}
authorType is one of staff, customer, superadmin or system. A system entry is a generated triage note and renders as a timeline chip rather than a chat bubble. mine is true when the calling principal wrote it.
Internal notes are removed in the database query, not in the interface. A requester cannot obtain them by reading the raw response. Messages with
internal: trueonly ever appear for the agent side.
Post a message
POST /api/tickets/{id}/messages
Either side may reply while the ticket is not Closed.
| Name | Type | Required | Description |
|---|---|---|---|
message | string | Conditional | Trimmed. Maximum 5000 characters. Required unless at least one attachment is supplied. |
internal | boolean | No | Leave an agent-only note. Coerced to false for anyone not on the agent side. |
attachments | array | No | Up to 5 items. Extras beyond five are dropped without error. |
Each attachment is an object describing a blob already uploaded via POST /api/upload.
| Field | Type | Required | Description |
|---|---|---|---|
pathname | string | Yes | Blob pathname. An item without it is skipped. |
name | string | No | Display name, truncated to 200 characters. Defaults to attachment. |
size | number | No | Defaults to 0. |
contentType | string | No | Truncated to 100 characters. Defaults to application/octet-stream. |
Attachments are private blobs read back through GET /api/blob, which re-checks the pathname at read time. Storing one here does not make it public.
curl -X POST https://goldpro.shop/api/tickets/cmf4tkt000001s601h3j5k7m9/messages \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "message": "Re-weighed at 14.2g net. Issuing a corrected certificate today." }'
Returns 201 with the created message in the shape shown above.
{
"success": true,
"message": {
"id": "cmf4msg000003s601f2g4h6j8",
"ticketId": "cmf4tkt000001s601h3j5k7m9",
"authorType": "staff",
"authorId": "cmf1usr00001s601t2v4w6x8",
"authorName": "Ravi Rohatgi",
"body": "Re-weighed at 14.2g net. Issuing a corrected certificate today.",
"internal": false,
"attachments": [],
"createdAt": "2026-07-16T09:44:02.611Z",
"mine": true
}
}
Posting has three side effects worth knowing:
- Activity moves, unless the note is internal.
lastMessageAtis bumped for a normal reply. An internal note is invisible to the requester, so bumping it would show them unread mail they cannot read. - Writing counts as reading. Your own side's seen stamp is moved, so your reply does not come back to you as unread.
- A requester's reply to a
Resolvedticket reopens it toOpenand clearsresolvedAt. That is what "it is still broken" means in practice. An agent replying does not reopen it.
| Status | Body | Cause |
|---|---|---|
400 | {"success":false,"error":"This ticket is closed. Raise a new one to continue."} | The ticket is Closed. Resolved still accepts replies. |
400 | {"success":false,"error":"A message is required."} | Empty body text and no valid attachments. |
400 | {"success":false,"error":"Message must be 5000 characters or fewer."} | Over the limit. |
401 | {"error":"Unauthorized"} | No valid principal. |
404 | {"error":"Ticket not found"} | No such ticket, or you cannot read it. |
Mark a thread seen
POST /api/tickets/{id}/seen
Stamps your own side of the thread as read. Which of the two watermarks moves is derived from your principal, so a requester can never mark the agent side read, or the reverse. Takes no body.
curl -X POST https://goldpro.shop/api/tickets/cmf4tkt000001s601h3j5k7m9/seen \
-H "Authorization: Bearer $TOKEN"
{ "success": true }
| Status | Body | Cause |
|---|---|---|
401 | {"error":"Unauthorized"} | No valid principal. |
404 | {"error":"Ticket not found"} | No such ticket, or you cannot read it. |
500 | {"error":"Internal Server Error"} | The stamp could not be written. |
Unread count
GET /api/tickets/unread-count
The badge count for the navigation. A narrow endpoint of its own rather than a field on the list, because every signed-in client polls it on a timer and it must not drag 200 serialized tickets along with it.
Only Open and In Progress tickets are considered — a resolved or closed ticket is never unread. The scan is capped at 500 tickets.
curl https://goldpro.shop/api/tickets/unread-count \
-H "Authorization: Bearer $TOKEN"
{ "unreadCount": 2, "openCount": 7 }
A side has unread mail when lastMessageAt is newer than that side's own seen stamp, or when it has never been stamped at all. Since "unread" depends on which side you are on, and that depends on the ticket's scope, it is computed per row rather than as a single query.
This endpoint does not report failures. If the count cannot be computed it returns 200 with both fields at 0 rather than an error status, because a badge must never break the shell that renders it. The only non-200 it produces is 401 when the principal is missing.