Linkit

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

CallerList / get / comment / closeKPIsSet statusRate
Ticket authorOwn tickets403 Support access required.403 Support access required.Own closed ticket, once
Support (queue grant)Whole queue200200403 Only the ticket author can rate resolution.
Support (assigned-only)Tickets that name them200200Author only
Commercial / otherOwn tickets if any403403Author 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

EndpointAuthPurpose
GET /api/v1/ticketsAny sessionPage of tickets the caller may see
POST /api/v1/ticketsAny sessionCreate. 201
GET /api/v1/tickets/kpisSupportFleet totals
GET /api/v1/tickets/{id}Support or authorOne ticket plus comments
POST /api/v1/tickets/{id}/commentsSupport or author201. Closed tickets refuse
PATCH /api/v1/tickets/{id}/closeSupport or authorIdempotent
PATCH /api/v1/tickets/{id}/rateAuthor only1–5, closed, once
PATCH /api/v1/tickets/{id}/statusSupport onlyopen / 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
QueryDefaultNotes
page1Non-positive or unparseable stays 1
perPage30Honoured 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/tickets
curl -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."
FieldRequiredNotes
titleyes (after trim)
bodyyes (after trim)
prioritynoBlank becomes "normal"
organization_idnoBlank 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_bccnoNotification 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/kpis

Support 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 openin_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.