Tickets

List, read, create, update, move, archive, delete, and redact tickets through the SAQ REST API, with every filter, body field, response field, and error.

Written for
integration-developer
Roles
owner, admin, member, elevated-client-user, agent
Requires
API token with read scope for GET, write scope for changes, triage scope for the Inbox; Archive, delete, and redact: browser session only
Feature
tickets

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.