API reference

How the SAQ REST API reference is organised, which operations accept API tokens, where the live OpenAPI document is, and what every error code means.

Written for
integration-developer
Roles
owner, admin, member, elevated-client-user, agent
Requires
An API token for operations marked token-enabled; A browser session for everything else
Feature
api

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.