This reference documents every HTTP operation of the SAQ API, grouped by resource. Every page follows the same pattern per operation: purpose, who may call it and with which token scope, parameters, a request example, a response example, and the errors it can return. The API is unversioned; workspace operations live under /api/w/{slug}/…, where {slug} is the workspace slug shown in the app's address bar (acme in every example here).
How the reference is organised
| Page | Operations |
|---|---|
| Public endpoints | Health, plan catalogue, login discovery, invitation landing |
| Tickets | List, read, create, update, move, archive, delete, redact |
| Comments and attachments | Comments, uploads, downloads, markdown preview |
| Viewers, links, and GitHub | Sharing, parent and child, blocks and relates, GitHub references |
| Boards and My work | Board columns, column paging, personal lists |
| Projects | Projects and project members |
| Time entries | Read, log, edit, delete, correct, re-attribute |
| Periods and reports | Period list, close preview, close, reports |
| Documents | Client timesheets and their download |
| Workflow | Kinds, states, priorities, labels, workflow settings |
| API tokens and agents | Minting, listing, revoking, agent accounts, MCP setup |
| Session-only operations | Everything an API token can never call |
Shared conventions (ids, dates, pagination, filtering, rate limits, body limits) are on Conventions; authentication for integrations is on Authentication.
Which operations accept API tokens
An API token (Authorization: Bearer saq_YOUR_TOKEN) is accepted only on the operations below. Every other /api/w/{slug}/… operation answers 403 forbidden with the message "This operation is not available to API keys", and tokens can never reach /api/me/*.
| Scope needed | Operations |
|---|---|
read (always implied) |
GET /tickets, GET /tickets/{ref}, GET /board, GET /my, GET /workflow, GET /time, GET /projects, GET /projects/{key}, GET /documents, GET /documents/{id}/download, GET /attachments/{id} |
write |
POST /tickets, PATCH /tickets/{ref}, POST /tickets/{ref}/move, POST /tickets/{ref}/comments, POST /tickets/{ref}/attachments, POST /tickets/{ref}/links, DELETE /tickets/{ref}/links/{linkId}, PUT /tickets/{ref}/parent, POST /tickets/{ref}/github, DELETE /tickets/{ref}/github/{linkId}, POST /time, PATCH /time/{id} |
triage |
Needed in addition to the above to see Inbox tickets and to move a ticket out of the Inbox. A token restricted to named projects never sees the Inbox. |
All paths in the table are relative to /api/w/{slug}. A GET needs read; any other method needs write. A token without the needed scope gets 403 forbidden with the message "This token does not include that scope" and details.scope. Behind the scope check, the same role rules apply as in the app: a token acts as the person or agent account it belongs to and never sees more than they would.
Every operation page states "Token: yes" or "Token: no (browser session only)". Session-only operations are called by the app with a cookie session; integrators cannot call them.
The live OpenAPI document
GET /api/openapi.json returns the OpenAPI 3.1 document for the whole API, titled "SAQ API". It is public and needs no authentication, so you can fetch it in a build step and generate a client from it. It covers every operation, including the ones that stream a file, the MCP endpoint, and SCIM. The response is cacheable for five minutes. There is no hosted Swagger UI; load the document into the tool of your choice.
curl -s https://saq.no/api/openapi.json -o saq-openapi.json
Each operation declares what it accepts, so a generated client and an API explorer configure authentication on their own:
| Scheme | What it is |
|---|---|
bearerAuth |
A workspace API key, Authorization: Bearer saq_… |
sessionCookie |
The browser session the app itself uses; not issuable to an integration |
scimToken |
A SCIM token, valid on /scim/v2 only |
stripeSignature |
Stripe's webhook signature; sent by Stripe, not by you |
An operation that lists bearerAuth is one of the operations in the table above; the list in the document is generated from the same rule the server enforces, so the two cannot disagree. An operation with an empty requirement needs no credential at all.
The document describes operations, not your permissions: an operation listing bearerAuth does not mean your token may call it, because roles, scopes, and project restrictions still apply. This page and the pages under it state, per operation, who may call it. A check in SAQ's own build fails when the document and these pages disagree, so a route cannot be added, renamed, or removed without the documentation following it.
Error responses
Every error is JSON with a stable error code, usually a message, and sometimes details:
{
"error": "invalid",
"message": "Validation failed",
"details": { "issues": [] }
}
error |
HTTP status | When |
|---|---|---|
unauthenticated |
401 | No session or token, or an unknown, expired, or revoked token |
session_expired |
401 | The browser session reached its absolute limit |
forbidden |
403 | The role, scope, or allow-list refuses the operation; a token used outside its CIDR |
mfa_required |
403 | An Owner or Admin session without two-factor authentication |
sso_required |
403 | The workspace forces single sign-on for this person's email domain |
reauth_required |
403 | The operation needs a re-authentication within the last ten minutes |
cross_site |
403 | A cookie-authenticated write from another origin (never for Bearer requests) |
read_only |
403 | The workspace is read-only (lapsed subscription, expired trial, or scheduled deletion) |
plan_required |
403 | The current plan does not include the feature |
invalid |
400 | Validation failed; details.issues lists the fields, or details.reason names the rule |
not_found |
404 | Unknown path, unknown id, or a ticket the actor may not see (existence is never revealed) |
conflict |
409 | The change contradicts the current state (details.reason says which rule) |
last_owner, seat_limit |
409 | A workspace needs one Owner; the plan has no seats left |
rate_limited |
429 | A per-address, per-token, per-workspace, or per-route budget is spent |
unavailable |
503 | A dependency is temporarily unavailable |
internal |
500 | Unexpected failure; retry later |
details.reason is a stable snake_case slug (for example range_limit, closed_period_log, reset_requires_admin) and is listed per operation in this reference. details.field names the offending body field where one exists.
Schema names
Response and request shapes are named after the schemas in the OpenAPI document, for example TicketSummary, TicketPage, CreateTicket, TimeEntry, Document, Workflow, ApiToken. Each operation page lists the fields of the schemas it uses, so a page can be read on its own.