API conventions

Base URL, JSON, identifiers, dates, paging, filtering, the error format, rate limits, idempotency, read-only workspaces, and size limits shared by every REST operation.

Written for
integration-developer
Roles
Requires
An API token
Feature
api

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 example acme). A token is bound to one workspace.
  • The API is unversioned: there is no /api/v1.
  • Ticket routes take {ref}: the human id such as ACME-184, the current prefix with an old number, or the ticket UUID.
  • Project routes take the project key (a lowercase slug such as website), not the UUID. Filters such as projectId take 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 (humanId in 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 key next to their id. GET /workflow lists 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) are YYYY-MM-DD with no time zone.
  • A time entry's optional startedAt is a wall-clock time HH:MM or HH:MM:SS; every entry carries the timezone (IANA name, for example Europe/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 id or humanId in 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.