These conventions apply to every operation under /api/w/{slug}/…. Read them once; the per-resource pages in the API reference assume them.
Base URL and paths
- Base URL:
https://saq.no. - Workspace routes:
/api/w/{slug}/…, where{slug}is the workspace address (for exampleacme). A token is bound to one workspace. - The API is unversioned: there is no
/api/v1. - Ticket routes take
{ref}: the human id such asACME-184, the current prefix with an old number, or the ticket UUID. - Project routes take the project
key(a lowercase slug such aswebsite), not the UUID. Filters such asprojectIdtake the UUID.
JSON in and out
Send Content-Type: application/json bodies and receive JSON, except for file uploads (multipart/form-data) and downloads (attachments and client timesheets, which stream bytes with a Content-Disposition header). Unknown route: 404 { "error": "not_found" }.
Identifiers
- All ids are server-generated UUIDs (version 7, so they sort by creation time). Example:
01924c3e-6b2a-7d11-9d0e-3a1f4b5c6d7e. - Tickets also carry a human id
PREFIX-N(humanIdin responses). The prefix is 2–6 uppercase letters or digits; numbers are never reused. If the workspace changes its prefix, old ids keep resolving. - Workflow objects (kinds, states, priorities) have a stable
keynext to theirid.GET /workflowlists them.
Dates and times
- Timestamps (
createdAt,updatedAt,closedAt,expiresAt) are ISO-8601 strings in UTC:2026-09-19T10:15:00.000Z. - Civil dates (
spentOn,dueDate,startDate,periodStart) areYYYY-MM-DDwith no time zone. - A time entry's optional
startedAtis a wall-clock timeHH:MMorHH:MM:SS; every entry carries thetimezone(IANA name, for exampleEurope/Oslo) it was logged in. SAQ has no timer: an entry is a date plus a duration in minutes.
Pagination
List operations return { "items": [...], "nextCursor": "..." }. Pass cursor=<nextCursor> to get the next page; nextCursor is null on the last page. limit is 1–50 (default 50). Cursors are opaque; do not build them yourself. A malformed cursor answers 400 invalid with details.reason: "bad_cursor".
GET /tickets orders by updatedAt descending, then id. GET /board returns one nextCursor per column; the column-paging route GET /board/{stateId} is browser-only, so tokens should use GET /tickets?stateIds=… to page a single state instead.
Filtering tickets
GET /tickets and GET /board accept these query parameters, all optional:
| Parameter | Values |
|---|---|
projectId |
project UUID |
queue |
inbox (triage scope, unrestricted token) |
stateIds, kindIds, labelIds, priorityIds |
comma-separated UUIDs, at most 20 |
stateCategory |
open or closed |
assigneeId, reporterId, viewerId |
a person UUID, me, or none |
billingClientId |
a client UUID or none (internal work) |
billingPolicy |
paid, free, or bank:<uuid> |
due |
overdue, week (due within 7 days), none |
q |
full-text search over title, description, and readable comments (≤200 characters) |
archived |
true to list archived tickets instead of live ones |
There is no sort parameter; order is fixed.
Errors
Every error body has the shape { "error": "<code>", "message": "...", "details": { ... } }. message is English text for developers; details is optional and may carry a stable reason slug and a field.
| HTTP | error |
Meaning |
|---|---|---|
| 400 | invalid |
Validation failed or a business rule refused the input |
| 401 | unauthenticated |
No valid token |
| 403 | forbidden |
Not permitted for this token or owner |
| 403 | reauth_required, mfa_required, sso_required |
Browser-session requirements (tokens never satisfy them) |
| 403 | read_only |
Writes refused while the workspace is read-only |
| 403 | plan_required |
The plan does not include the feature |
| 404 | not_found |
Unknown or invisible resource |
| 409 | conflict |
The state of the data refuses the change (for example a closed period) |
| 409 | last_owner, seat_limit |
Membership rules |
| 429 | rate_limited |
A rate budget is spent |
| 503 | unavailable |
Temporarily unavailable; retry later |
Schema validation errors carry the failing issues:
{
"error": "invalid",
"message": "Validation failed",
"details": {
"issues": [
{
"path": ["title"],
"message": "Too small: expected string to have >=1 characters"
}
]
}
}
Rate limits
Three fixed one-minute budgets apply to every token request, on HTTP and MCP alike:
| Budget | Limit | Message |
|---|---|---|
| Per client address | 600 requests per minute, spent before the token is even looked up | "Too many API requests from this address" |
| Per token | 120 requests per minute | "This token has reached its rate limit" |
| Per workspace | The plan's budget shared by every token in the workspace: Team 300, Business 1 200, Enterprise 3 000 per minute | "This workspace has reached its plan's API rate limit" |
Some routes have an extra budget per identity: POST /tickets/{ref}/attachments 30 uploads per minute (the reports budget of 30 per minute applies to browser sessions; reports are not open to tokens). Exceeding any budget answers 429 rate_limited. No Retry-After header is sent: wait until the next minute and retry, and spread bulk work so that one token stays below 120 requests per minute.
Idempotency
There is no Idempotency-Key header. Creates (POST /tickets, POST /time, POST /tickets/{ref}/comments) run once per request, so a retried request creates a second record. To make creates safe:
- Search before you create:
GET /tickets?q=<your external reference>and reuse the match. - Store the returned
idorhumanIdin your system as soon as the create succeeds, and never retry a request whose response you received. - Put your own reference (an order number, an alert id) in the ticket title or description so it is searchable.
Billing runs take an idempotencyKey in their body, but billing runs are browser-only and never available to tokens.
Read-only workspaces
When a subscription lapses, or a workspace is scheduled for deletion, it becomes read-only. Reads keep working; every write answers 403 read_only with "This workspace is read-only until the subscription is restored". An Owner restores it under Settings → Organization.
Size limits
- JSON bodies: 1 MiB.
- Attachments: 20 MiB per file ("Attachments are limited to 20 MiB."). Executables, scripts, and files whose bytes do not match their declared type are refused.
- Ticket descriptions and comment bodies: 50 000 characters. Time entry notes: 10 000 characters.
Source attribution
Tickets and comments created through the REST API are stored with source: "api"; those created through MCP carry source: "mcp". The app shows the source in the timeline, and a personal token's actions also record that they were performed on the owner's behalf.