SAQ has no webhooks, so a mirror of its tickets in another system is kept fresh by polling. This recipe shows a pattern that fetches only what changed, handles archived tickets, respects visibility, and stays inside the rate budget.
How the ticket list is ordered
GET /api/w/acme/tickets returns tickets with the newest updatedAt first, paged with an opaque cursor (up to 50 per page). updatedAt moves whenever anything happens on the ticket: a field edit, a state change, a comment, logged time, an attachment, a link, a viewer change, or a GitHub link. That makes "everything with updatedAt newer than my last run" a complete change set.
The polling loop
- Remember the
updatedAtof the newest ticket you processed in the previous run (lastSeen). On the first run, use a date far in the past. - Fetch the first page:
curl "https://saq.no/api/w/acme/tickets?limit=50" \
-H "Authorization: Bearer saq_YOUR_TOKEN"
- Walk the
itemsin order. Stop as soon as an item'supdatedAtis not newer thanlastSeen; everything after it is unchanged. - If you reached the end of the page without stopping and
nextCursoris notnull, fetch the next page withcursor=<nextCursor>and continue. - When done, set
lastSeento the newestupdatedAtyou saw in this run.
Because a ticket that changes during your walk moves to the front of the list, a change can appear on a page you already passed only to be picked up on the next run; the pattern never loses a change, it may just see one twice. Make your handling of a ticket idempotent (upsert by id, never by title).
The list contains summaries only (title, kind, state, priority, project, assignee, reporter, labels, dates, estimate, commentCount). If you mirror descriptions, comments, or attachments, fetch GET /api/w/acme/tickets/{ref} for each changed ticket.
Detecting state changes
The ticket page carries an events array, oldest first. Each event has kind, actor, subject, payload, and createdAt. State changes are events with kind: "state" and a payload { "from": "<old state id>", "to": "<new state id>" }; other kinds include created, title, assignee, kind, priority, due, start, project, estimate, viewer_added, viewer_removed, parent, link_added, link_removed, archived, restored, attachment, github_linked, github_unlinked, time, and redacted. Keep the id of the last event you processed per ticket and act on the ones after it. The summary's state.category (open or closed) and the page's closedAt tell you whether a ticket is currently closed.
Archived tickets
Archived tickets are left out of the normal list. To notice archiving, either poll GET /api/w/acme/tickets?archived=true with the same updatedAt logic (archiving bumps updatedAt and writes an archived event), or treat a ticket that has vanished from the live list as archived once GET /tickets/{ref} shows "archived": true. Deleted tickets answer 404 not_found; SAQ keeps no tombstone you can read through the API.
Visibility
A token sees exactly what its owner sees. A personal token owned by a Member sees tickets on that person's projects plus tickets they report or are a viewer on; a project-scoped token sees only its projects. Tickets outside that set are not listed, and reading one by id answers 404 not_found, never 403. For a workspace-wide mirror, use an agent account key: agents are members of every project. Inbox tickets appear only for tokens with the triage scope and no project restriction, and only when you ask for them with queue=inbox.
Comments have audiences: a token owned by a client user receives only shared comments, and the ticket page's permissions.read lists the audiences the token may read.
Rate budget
Each token may make 120 requests per minute, and every token in the workspace shares the plan's budget (Team 300, Business 1 200, Enterprise 3 000 per minute). A workspace with 2 000 live tickets costs 40 list requests for a full pass; an incremental run usually costs one or two. Fetching every changed ticket's page costs one request each, so batch detail fetches and poll at an interval that fits your budget (every minute is fine for a few dozen changes). A 429 rate_limited answer means wait until the next minute; there is no Retry-After header.
Writing back
If your system also changes tickets, use PATCH /api/w/acme/tickets/{ref} for fields and state (stateId), POST …/comments for comments, and POST …/move to change project. Every write bumps updatedAt, so your own writes come back in the next poll; ignore changes whose latest event has your token's owner as actor if you need to avoid loops.