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:

FieldAllowed values
scopeCUSTOMER_TO_TENANT, STAFF_TO_TENANT, TENANT_TO_PLATFORM
categorySUPPORT, COMPLAINT, SUGGESTION
statusOpen, In Progress, Resolved, Closed
priorityLow, 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:

ScopeAgent side
CUSTOMER_TO_TENANTAny staff member of the owning tenant.
STAFF_TO_TENANTOnly the owning tenant's admins.
TENANT_TO_PLATFORMSuper admins only.

Who sees what, per principal:

PrincipalVisible tickets
CustomerOnly tickets they personally raised, in their own tenant.
Tenant employeeTheir tenant's CUSTOMER_TO_TENANT tickets, plus STAFF_TO_TENANT tickets they raised themselves — not a colleague's.
Tenant adminAll three scopes within their own tenant.
Super adminTENANT_TO_PLATFORM only, across every tenant.

Super admins are deliberately not granted access to CUSTOMER_TO_TENANT or STAFF_TO_TENANT threads. 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:

PrincipalCan open
CustomerCUSTOMER_TO_TENANT
Tenant employeeSTAFF_TO_TENANT
Tenant adminTENANT_TO_PLATFORM
Super adminNothing. 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.

NameTypeRequiredDescription
scopestringNoNarrow to one scope. Ignored unless it is a valid scope value.
categorystringNoNarrow to one category. Ignored unless valid.
statusstringNoNarrow to one status. Ignored unless valid.
tenantIdstringNoNarrow 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.

NameTypeRequiredDescription
subjectstringYesTrimmed. Maximum 150 characters.
messagestringYesThe opening message. Trimmed. Maximum 5000 characters.
scopestringNoMust be one of your creatableScopes. Defaults to your only permitted scope, so simple clients can omit it.
categorystringNoDefaults 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.

StatusBodyCause
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.

NameTypeRequiredDescription
statusstringNoOne of the four statuses.
prioritystringNoOne of the four priorities.
assignToMebooleanNoClaim the ticket for yourself.
unassignbooleanNoRelease 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.

StatusBodyCause
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.

NameTypeRequiredDescription
afterstringNoISO 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: true only ever appear for the agent side.

Post a message

POST /api/tickets/{id}/messages

Either side may reply while the ticket is not Closed.

NameTypeRequiredDescription
messagestringConditionalTrimmed. Maximum 5000 characters. Required unless at least one attachment is supplied.
internalbooleanNoLeave an agent-only note. Coerced to false for anyone not on the agent side.
attachmentsarrayNoUp to 5 items. Extras beyond five are dropped without error.

Each attachment is an object describing a blob already uploaded via POST /api/upload.

FieldTypeRequiredDescription
pathnamestringYesBlob pathname. An item without it is skipped.
namestringNoDisplay name, truncated to 200 characters. Defaults to attachment.
sizenumberNoDefaults to 0.
contentTypestringNoTruncated 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. lastMessageAt is 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 Resolved ticket reopens it to Open and clears resolvedAt. That is what "it is still broken" means in practice. An agent replying does not reopen it.
StatusBodyCause
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 }
StatusBodyCause
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.