Keep an external system in sync

A polling pattern that mirrors SAQ tickets into another system without webhooks, using cursor pagination, updatedAt, the ticket timeline, and a rate budget that stays within limits.

Written for
integration-developer
Roles
Requires
API token with the read scope
Feature
tickets

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

  1. Remember the updatedAt of the newest ticket you processed in the previous run (lastSeen). On the first run, use a date far in the past.
  2. Fetch the first page:
curl "https://saq.no/api/w/acme/tickets?limit=50" \
  -H "Authorization: Bearer saq_YOUR_TOKEN"
  1. Walk the items in order. Stop as soon as an item's updatedAt is not newer than lastSeen; everything after it is unchanged.
  2. If you reached the end of the page without stopping and nextCursor is not null, fetch the next page with cursor=<nextCursor> and continue.
  3. When done, set lastSeen to the newest updatedAt you 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.