Viewers are explicit access grants on one ticket; links relate tickets to each other; GitHub links are stored references to commits and pull requests (SAQ has no GitHub app and never fetches from GitHub). Editing a ticket's links needs the same permission as editing the ticket (permissions.edit on the ticket page).
POST /api/w/{slug}/tickets/{ref}/viewers
Share the ticket with people. Token: no (browser session only). Needs permissions.addViewers. Every grant is a timeline event.
Body (AddViewers): { "identityIds": ["…"] }, 1–20 identity ids. Response 200 { "added": ["…"] } with the ids that were granted (already-present viewers are omitted).
| Error | Status | details.reason |
|---|---|---|
not_found |
404 | unknown_person (with identityId), or ticket not visible |
forbidden |
403 | share_forbidden (with identityId, name): that person cannot be given access |
DELETE /api/w/{slug}/tickets/{ref}/viewers/{identityId}
Remove a viewer. Token: no (browser session only). The reporter can never be removed. Response 200 { "ok": true }. Errors: forbidden 403 ("The reporter can never be removed" or "You cannot remove this viewer"), not_found 404.
POST /api/w/{slug}/tickets/{ref}/visibility
Preview who will see a comment at an audience with these mentions, before posting it. Token: no (browser session only).
Body (VisibilityRequest): { "audience": "internal", "mentions": [] } (mentions at most 20). Response 200 (VisibilityPreview): audience, workspaceUsers (count), clientPeople[] (identityId, name, clientName), willGainAccess[] (same shape; mentions that become viewers), denied[] (identityId, name; mentions that would be refused).
PUT /api/w/{slug}/tickets/{ref}/parent
Set or clear the parent. Token: yes, write. Cycles are refused and a parent chain is limited to 10 edges. A parent's estimate is the sum of its children.
Body (SetParent): { "parentId": "01924c3e-8f10-7a22-b3c4-5d6e7f8a9b0d" } or { "parentId": null }.
curl -X PUT https://saq.no/api/w/acme/tickets/ACME-184/parent \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "parentId": "01924c3e-8f10-7a22-b3c4-5d6e7f8a9b0d" }'
Response 200 { "ok": true }.
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | self_parent |
conflict |
409 | own_ancestor, parent_cycle, parent_depth (with depth) |
forbidden |
403 | Cannot edit the ticket |
not_found |
404 | Ticket or parent not visible |
POST /api/w/{slug}/tickets/{ref}/links
Add a link from this ticket to another. Token: yes, write. A blocks link means this ticket blocks the target; the target shows it under blockedBy. Blocking chains are limited to 10 edges and may not form a cycle. A link that already exists is not duplicated.
Body field (LinkInput) |
Type | Required | Meaning |
|---|---|---|---|
targetId |
uuid | yes | The other ticket (must be visible to the caller) |
kind |
blocks, relates, duplicates |
yes | Link kind |
curl -X POST https://saq.no/api/w/acme/tickets/ACME-184/links \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "targetId": "01924c3e-8f10-7a22-b3c4-5d6e7f8a9b0e", "kind": "blocks" }'
Response 201 { "id": "01924c3e-0c13-7d24-ae45-6f7a8b9c0d1e" }; id is null when the link already existed. Events are written on both tickets.
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | self_link |
conflict |
409 | dependency_cycle, dependency_depth (with depth) |
forbidden |
403 | Cannot edit the ticket |
not_found |
404 | Source or target not visible |
DELETE /api/w/{slug}/tickets/{ref}/links/{linkId}
Remove a link by its linkId (from the ticket page's links). Token: yes, write. Response 200 { "ok": true }. Errors: forbidden 403, not_found 404.
POST /api/w/{slug}/tickets/{ref}/github
Store a GitHub reference on the ticket. Token: yes, write. Exactly one of sha or pr must be given.
Body field (GithubLinkInput) |
Type | Required | Validation |
|---|---|---|---|
repo |
string | yes | owner/name, 1–200 characters |
url |
string | yes | An https://github.com/… URL, at most 500 characters |
sha |
string or null | one of | 7–40 hex characters |
pr |
positive integer or null | one of | Pull request number |
curl -X POST https://saq.no/api/w/acme/tickets/ACME-184/github \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{ "repo": "acme/website", "pr": 412, "url": "https://github.com/acme/website/pull/412" }'
Response 201 (GithubLink)
{
"id": "01924c3e-0d14-7e25-bf56-7a8b9c0d1e2f",
"repo": "acme/website",
"sha": null,
"pr": 412,
"url": "https://github.com/acme/website/pull/412",
"createdAt": "2026-09-19T10:30:00.000Z"
}
| Error | Status | details.reason |
|---|---|---|
invalid |
400 | repo_format, github_url, sha_format, pr_format, sha_or_pr_required (each with field) |
forbidden |
403 | Cannot edit the ticket |
not_found |
404 | Ticket not visible |
DELETE /api/w/{slug}/tickets/{ref}/github/{linkId}
Remove a GitHub reference. Token: yes, write. Response 200 { "ok": true }. Errors: forbidden 403, not_found 404.
GET /api/w/{slug}/github/links
Every GitHub reference on tickets the caller may read, newest first, at most 500. Token: no (browser session only); the app shows it under Settings → Channels → GitHub.
Response 200 { "links": WorkspaceGithubLink[] } where each entry is a GithubLink plus ticket (id, humanId, title).