Tickets are the central object of SAQ. These operations read and change them; comments, attachments, viewers, and links have their own pages. A {ref} path parameter accepts the human id (ACME-184), the current prefix with an old number, or the UUID; it is at most 60 characters. A ticket the caller may not see answers 404 not_found, never 403.
Tickets and comments created with a token carry source: "api" and, for a delegated token, are attributed to the person who minted it.
GET /api/w/{slug}/tickets
The tickets the caller may see, newest activity first, filtered. Token: yes, read; triage to include Inbox tickets.
| Query parameter | Type | Meaning |
|---|---|---|
projectId |
uuid | Only this project |
queue |
inbox |
Only the Inbox (workspace users with triage; a project-restricted token never sees it) |
stateIds, kindIds, labelIds, priorityIds |
comma-separated uuids | At most 20 each |
stateCategory |
open or closed |
By state category |
assigneeId, reporterId, viewerId |
uuid, me, or none |
Filter by person |
billingClientId |
uuid or none |
Billing client; none is internal work |
billingPolicy |
string (≤60) | paid, free, or bank:<uuid> |
due |
overdue, week, or none |
Due date filter |
q |
string (≤200) | Full text over title, description, and readable comments |
archived |
true |
Archived tickets only |
cursor |
string (≤200) | nextCursor from the previous page |
limit |
integer 1–50 | Page size; default 50 |
Request
curl "https://saq.no/api/w/acme/tickets?projectId=01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e&stateCategory=open&limit=2" \
-H 'Authorization: Bearer saq_YOUR_TOKEN'
Response 200 (TicketList)
{
"items": [
{
"id": "01924c3e-8f10-7a22-b3c4-5d6e7f8a9b0c",
"humanId": "ACME-184",
"title": "Login page shows stale session banner",
"kind": {
"id": "01924c3e-0001-7000-8000-000000000001",
"key": "bug",
"name": "Bug"
},
"state": {
"id": "01924c3e-0002-7000-8000-000000000003",
"key": "in_progress",
"name": "In progress",
"category": "open",
"position": 2
},
"priority": {
"id": "01924c3e-0003-7000-8000-000000000003",
"key": "high",
"name": "High",
"position": 3
},
"project": {
"id": "01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e",
"key": "web",
"name": "Website"
},
"queue": null,
"assignee": {
"id": "01924c3e-0004-7000-8000-000000000001",
"name": "Kari Nordmann",
"clientName": null
},
"reporter": {
"id": "01924c3e-0004-7000-8000-000000000002",
"name": "Ola Hansen",
"clientName": "Acme AS"
},
"billingClient": {
"id": "01924c3e-0005-7000-8000-000000000001",
"name": "Acme AS"
},
"labels": [
{ "id": "01924c3e-0006-7000-8000-000000000001", "name": "frontend" }
],
"dueDate": "2026-09-26",
"startDate": null,
"estimateMinutes": 120,
"parentId": null,
"commentCount": 3,
"createdAt": "2026-09-18T08:12:00.000Z",
"updatedAt": "2026-09-19T10:15:00.000Z"
}
],
"nextCursor": "MjAyNi0wOS0xOVQxMDoxNTowMC4wMDBafDAxOTI0YzNl"
}
TicketSummary fields: id, humanId, title, kind (id, key, name), state (id, key, name, category, position), priority (id, key, name, position), project or null, queue (inbox, review, or null), assignee and reporter (id, name, clientName) or null, billingClient or null, labels[], dueDate, startDate, estimateMinutes, parentId, commentCount, createdAt, updatedAt. nextCursor is null on the last page.
| Error | Status | When |
|---|---|---|
invalid |
400 | Bad query value; details.reason: "bad_cursor" for a malformed cursor |
forbidden |
403 | Token without read, or outside the allow-list |
GET /api/w/{slug}/tickets/{ref}
The full ticket page. Token: yes, read.
Request
curl https://saq.no/api/w/acme/tickets/ACME-184 \
-H 'Authorization: Bearer saq_YOUR_TOKEN'
Response 200 (TicketPage), every TicketSummary field plus:
| Field | Type | Meaning |
|---|---|---|
description, descriptionHtml |
string | Markdown and sanitised HTML |
source |
string | ui, email, api, mcp, or github |
startedBy |
workspace, client, or email |
Who started the ticket |
archived |
boolean | Archived |
closedAt |
datetime or null | Last open-to-closed transition |
viewers[] |
Viewer | identityId, name, email, kind, clientName, addedBy, isReporter, canRemove |
billing |
object, absent for client users | billingClient, billingPolicy, billingMode, effectiveBillingMode, billingDescription, estimateMinutes, rollupEstimateMinutes (sum over visible descendants) |
comments[] |
Comment | Only the audiences the caller may read; see Comments and attachments |
events[] |
TicketEvent | id, kind, actor, subject, payload, createdAt |
attachments[] |
Attachment | id, filename, contentType, size, inline, audience, commentId, uploadedBy, createdAt |
links |
object | parent, children[], blocks[], blockedBy[], relates[], duplicates[]; each ref has id, humanId, title, state, and (except parent and children) linkId |
time |
object or null | loggedMinutes (workspace users only), billableMinutes, unbilledMinutes; null when not readable |
github[] |
GithubLink | id, repo, sha, pr, url, createdAt |
permissions |
TicketPermissions | edit, read[], comment[], defaultAudience, changeAssignee, moveState, customerResolveStateId, setBilling, changeKind, addViewers, move, archive, logTime, seeInternalFigures |
Use permissions to decide what to offer before sending a change.
| Error | Status | When |
|---|---|---|
not_found |
404 | Unknown ref or not visible |
POST /api/w/{slug}/tickets
Create a ticket. Token: yes, write. A workspace user needs to be a full member of the project (Owner and Admin: any project); projectId: null creates an Inbox ticket, which for a token needs triage.
Body field (CreateTicket) |
Type | Required | Validation |
|---|---|---|---|
projectId |
uuid or null | yes | null puts the ticket in the Inbox |
title |
string | yes | 1–200 characters, trimmed |
description |
string | no | Markdown, at most 50 000 characters |
kindId, stateId, priorityId |
uuid | no | Defaults: inbound kind, the reopen-target state, the workspace default priority |
assigneeId |
uuid or null | no | A workspace user or an elevated client user on the project |
billingClientId |
uuid or null | no | The project's billing client or a collaborating client; workspace users only |
labelIds |
uuid[] | no | At most 20 |
startDate, dueDate |
YYYY-MM-DD or null |
no | Civil dates |
estimateMinutes |
integer ≥ 0 or null | no | Leaf tickets only |
parentId |
uuid or null | no | Parent ticket |
Request
curl -X POST https://saq.no/api/w/acme/tickets \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"projectId": "01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e",
"title": "Renew TLS certificate",
"description": "Expires 2026-10-01.",
"dueDate": "2026-09-28",
"labelIds": ["01924c3e-0006-7000-8000-000000000002"]
}'
Response 201 (TicketSummary), the same shape as a list item.
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | Validation failed; unknown_kind, unknown_priority, unknown_state, unknown_label, unknown_person, assignee_ineligible, billing_client_scope, estimate_format |
not_found |
404 | unknown_project |
forbidden |
403 | Not allowed to start tickets on the project; billing_fields_forbidden; assign_forbidden; missing scope |
PATCH /api/w/{slug}/tickets/{ref}
Change fields. Token: yes, write. Every field is optional; send only what changes. Who may change what follows the ticket's permissions.
Body field (UpdateTicket) |
Type | Validation |
|---|---|---|
title |
string | 1–200 |
description |
string | ≤ 50 000 |
kindId, stateId, priorityId |
uuid | Must exist in the workflow |
assigneeId |
uuid or null | Eligible person |
labelIds |
uuid[] | ≤ 20, replaces the set |
startDate, dueDate |
date or null | |
billingClientId |
uuid or null | Workspace users on the project |
billingPolicy |
string (≤60) or null | paid, free, bank:<uuid>; null inherits |
billingMode |
period, on_close, or null |
null inherits from the project |
billingDescription |
string (≤500) or null | What the client reads on the timesheet |
estimateMinutes |
integer ≥ 0 or null | Refused on a parent ticket |
Request
curl -X PATCH https://saq.no/api/w/acme/tickets/ACME-184 \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "stateId": "01924c3e-0002-7000-8000-000000000005", "billingDescription": "Fixed session banner" }'
Response 200 (TicketPage).
| Error | Status | details.reason |
|---|---|---|
forbidden |
403 | field_forbidden (with field), state_forbidden, assign_forbidden, billing_fields_forbidden, kind change refused |
invalid |
400 | unknown_*, billing_client_scope, unknown_client, policy_format, estimate_format, parent_estimate |
conflict |
409 | restore_first: the ticket is archived |
not_found |
404 | Not visible |
POST /api/w/{slug}/tickets/{ref}/move
Move a ticket to another project, or route it out of the Inbox. Token: yes, write; triage when the ticket is in the Inbox. Outside the Inbox the caller must be a full member of both projects (Owner and Admin: any). A move that would reset the billing client is allowed only for an Owner or Admin who sends confirmReset: true.
Body field (MoveTicket) |
Type | Required | Meaning |
|---|---|---|---|
projectId |
uuid | yes | Target project |
billingClientId |
uuid or null | no | Set explicitly during triage |
kindId |
uuid | no | New kind |
assigneeId |
uuid or null | no | New assignee |
confirmReset |
boolean | no | Confirm a billing-client reset |
curl -X POST https://saq.no/api/w/acme/tickets/ACME-184/move \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "projectId": "01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7f", "confirmReset": true }'
Response 200 (TicketPage). Viewers, conversation, and links travel with the ticket; time is not re-attributed.
| Error | Status | details.reason |
|---|---|---|
not_found |
404 | unknown_project, or ticket not visible |
forbidden |
403 | Not a member of both projects; missing triage |
conflict |
409 | reset_requires_admin; confirm_reset (send confirmReset); moved_out_of_sight (moved, but you can no longer see it) |
invalid |
400 | billing_client_scope |
POST /api/w/{slug}/tickets/{ref}/archive
Archive or restore. Token: no (browser session only). Owner, Admin, or a full project member.
Body: { "archived": true } or { "archived": false }. Response 200 { "ok": true }. Errors: forbidden 403, not_found 404.
DELETE /api/w/{slug}/tickets/{ref}
Delete a ticket permanently. Token: no (browser session only). Owner or Admin with a re-authentication in the last ten minutes. Refused when any time on the ticket has been billed or lies in a closed period; redact instead.
Response 200 { "ok": true }.
| Error | Status | details.reason |
|---|---|---|
reauth_required |
403 | No recent re-authentication |
forbidden |
403 | Not Owner or Admin |
conflict |
409 | ticket_billed, period_closed |
POST /api/w/{slug}/tickets/{id}/redact
Replace the ticket's descriptive content with [redacted] and supersede the documents that quoted it. Token: no (browser session only). Owner or Admin with recent re-authentication. This path takes the UUID, not the human id.
Body: { "reason": "GDPR request 2026-09-19" } (3–500 characters). Response 200 { "ok": true, "runs": 2 } where runs is the number of billing runs whose documents were superseded.