Skip to content
The SAW API is in beta.

Authentication

The SAW API uses two credentials:

  • An API key (saw_live_…) identifies your integration. Organization admins create keys in Settings → Security → API keys. A key is long-lived, so keep it server-side and never embed one in a browser page or a mobile app.
  • An access token authorizes requests. It is a short-lived JWT, valid for about 15 minutes. You get one by exchanging the key.

The exchange is served by the Accounts API host:

POST https://accounts.dev.sawrun.com/api/v1/auth/token/api-key
Content-Type: application/json
{ "apiKey": "saw_live_…" }
{ "ok": true, "data": { "accessToken": "eyJ…", "tokenType": "Bearer", "expiresInSeconds": 900 } }

Send the token on every request to the workspace host:

GET https://workspace.dev.sawrun.com/api/v1/orgs/{orgId}/projects
Authorization: Bearer eyJ…

Reuse a token until shortly before expiresInSeconds runs out, then exchange again. Don’t exchange on every request, because the exchange has its own rate limit. If a call answers 401 unauthorized, the token has expired or its key was revoked: exchange once more, and stop if that fails. This is how saw.ts does it:

saw.ts (excerpt)
let token: { value: string; orgId: string; expiresAt: number } | undefined;
async function accessToken() {
// Access tokens live about 15 minutes; exchange again a minute before expiry.
if (!token || Date.now() > token.expiresAt - 60_000) {
const exchange: Schemas['ApiKeyExchange'] = { apiKey };
const data = await send<Schemas['AccessTokenData']['data']>(`${ACCOUNTS_URL}/auth/token/api-key`, 'post', exchange);
token = {
value: data.accessToken,
orgId: orgId ?? orgIdOf(data.accessToken),
expiresAt: Date.now() + data.expiresInSeconds * 1000,
};
}
return token;
}

Every workspace path starts with /orgs/{orgId}. A key belongs to exactly one organization, and the access token names it in its org_id claim:

saw.ts (excerpt)
/** The organization an access token acts for: its `org_id` claim. */
export function orgIdOf(accessToken: string): string {
const payload = accessToken.split('.')[1] ?? '';
const claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/'))) as { org_id?: string };
if (!claims.org_id) throw new Error('The access token names no organization.');
return claims.org_id;
}

A request for any other organization is refused with 403 forbidden.

A key’s Access setting decides what its tokens can do:

Access in Settings Scopes Allows
Board — read only tickets:read Read tickets, comments, projects, statuses, labels and cycles
Board — read & write tickets:read, tickets:write Also create, update, move and archive tickets; post comments; manage relations

An operation the key’s scopes don’t cover answers 403 forbidden. A key also can’t:

  • list tickets across the whole organization. GET /tickets needs a projectId.
  • delete a ticket. Archive it instead (POST /tickets/{ticketId}/archive).
  • create labels. That needs a signed-in organization admin.
  • move a ticket into a column with the building role. That would start an agent run.
  • see restricted projects. They don’t appear in its lists, and their tickets are refused.

Tickets and comments a key creates are attributed to the key, not to a person.

Revoke a key in Settings → Security. A revoked or expired key can no longer be exchanged, and access tokens already issued from it stop working within seconds. Choose an expiry when you create the key (30, 90 or 365 days, or never), and rotate keys by creating the new one before revoking the old one.