Support tickets
Raise, list, comment, close, rate, and re-status tickets on the Rust host
Support tickets
All eight ticket routes are mounted on the Rust host. Creating a ticket does not require SMTP: ticket_notifier is optional. With no notifier the row is still inserted (201) and the process logs that no mail went out. Go logs a send failure the same way and still answers 201.
Errors carry the platform envelope and are sentenized: {"data":{},"message":"Title and body are required.","status":400}.
Who sees what
| Caller | List / get / comment / close | KPIs | Set status | Rate |
|---|---|---|---|---|
| Ticket author | Own tickets | 403 Support access required. | 403 Support access required. | Own closed ticket, once |
| Support (queue grant) | Whole queue | 200 | 200 | 403 Only the ticket author can rate resolution. |
| Support (assigned-only) | Tickets that name them | 200 | 200 | Author only |
| Commercial / other | Own tickets if any | 403 | 403 | Author only |
A merchant JWT can raise a ticket. Support KPIs are a fleet aggregate — the React screen hides them unless the caller holds support_surface.
Quick reference
| Endpoint | Auth | Purpose |
|---|---|---|
GET /api/v1/tickets | Any session | Page of tickets the caller may see |
POST /api/v1/tickets | Any session | Create. 201 |
GET /api/v1/tickets/kpis | Support | Fleet totals |
GET /api/v1/tickets/{id} | Support or author | One ticket plus comments |
POST /api/v1/tickets/{id}/comments | Support or author | 201. Closed tickets refuse |
PATCH /api/v1/tickets/{id}/close | Support or author | Idempotent |
PATCH /api/v1/tickets/{id}/rate | Author only | 1–5, closed, once |
PATCH /api/v1/tickets/{id}/status | Support only | open / in_progress / closed |
The ticket object
Snake_case on the wire. Timestamps use the platform's legacy datetime rendering — 2026-09-04 12:34:56.000Z, a space rather than a T — including values the handler wrote as RFC 3339, because the column re-renders them. rating is a number; 0 means unrated.
{
"id": "abc123xyz789def",
"title": "POS printer offline",
"body": "Branch Riyadh-01 cannot print receipts.",
"status": "open",
"priority": "normal",
"organization_id": "0rmzqwt5hhaf07r",
"created_by": "6yuq0469q3r40vy",
"closed_by": "",
"assignee_user_ids": [],
"rating": 0,
"rated_at": "",
"first_response_at": "",
"closed_at": "",
"created": "2026-09-10 14:00:00.000Z",
"updated": "2026-09-10 14:00:00.000Z"
}GET /tickets/{id} adds comments. Each comment is {id, ticket_id, author_id, body, created, updated}.
Stored statuses are exactly open, in_progress, closed. Priority is free text; the admin form offers low / normal / high / urgent.
List
GET /api/v1/tickets?page=1&perPage=30&status=open| Query | Default | Notes |
|---|---|---|
page | 1 | Non-positive or unparseable stays 1 |
perPage | 30 | Honoured only inside 1..=100; anything else keeps 30 |
status | (none) | Trimmed and lower-cased. Closed matches closed. A value outside the three stored statuses is a 200 with zero items, not a 400 |
{
"page": 1,
"perPage": 30,
"totalItems": 0,
"items": []
}Live merchant JWT against an empty queue: 200 with that empty envelope.
Create
POST /api/v1/ticketscurl -X POST "https://api.linkit.works/v1/tickets" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "POS printer offline",
"body": "Branch Riyadh-01 cannot print receipts.",
"priority": "normal"
}'const res = await fetch('https://api.linkit.works/v1/tickets', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'POS printer offline',
body: 'Branch Riyadh-01 cannot print receipts.',
priority: 'normal',
}),
});
// 201 + the ticket object. Empty title/body → 400 "Title and body are required."| Field | Required | Notes |
|---|---|---|
title | yes (after trim) | |
body | yes (after trim) | |
priority | no | Blank becomes "normal" |
organization_id | no | Blank skips the membership check. A non-empty id the caller cannot access is 403 You do not have access to that organization. |
email_to / email_cc / email_bcc | no | Notification override. Default To is info@linkit.works. A whitespace-only email_to keeps the default; a non-empty cc/bcc list that parses to nothing clears those lists |
Live empty-body probe: 400 Title and body are required. — the route is mounted.
KPIs
GET /api/v1/tickets/kpisSupport only. Anyone else: 403 Support access required. (probed with a Commercial JWT).
{
"closed_count": 0,
"avg_close_minutes": 0,
"avg_rating": 0,
"rated_tickets_count": 0
}A repository failure here is 200 with zeroes, not a 500. An all-zero block cannot be told apart from an empty fleet.
Comment / close / rate / status
POST /tickets/{id}/comments { "body": "…" } → 201. Empty body: 400 body is required. Closed ticket: 400 Ticket is closed. The first Support reply stamps first_response_at and moves open → in_progress.
PATCH /tickets/{id}/close — no body. Already closed is 200 with the unchanged row.
PATCH /tickets/{id}/rate { "rating": 4 } — integer 1..=5, checked before the row is loaded (out of range against a missing id is 400, not 404). Must be closed and unrated. Author only.
PATCH /tickets/{id}/status { "status": "in_progress" } — Support only. Invalid status: 400 invalid status. Closing stamps closed_at / closed_by only when they are still empty.
Missing id on get/comment/close/rate/status: 404 Ticket not found.