# List and filter tickets

> Read every open ticket in a project, a page at a time, to report on a board or mirror it elsewhere.

Use this to read a board: to send a weekly report, to mirror tickets into another tool, or to find
the tickets to act on next. By the end you'll have every open ticket in a project, newest first.

**Before you start:** any key will do, read only is enough. To pick the project, set
`SAW_PROJECT_ID` to its ID. Without it, the recipe uses your organization's first project.

### Swap your key for an access token

Every recipe starts this way, as the [Quickstart](https://docs.bydawn.com/developers/quickstart/) explains.

### Choose the project

An API key always lists tickets one project at a time, so the recipe needs a project ID.
`GET /projects` lists them if you need to look one up.

### Read the tickets a page at a time

A project can hold thousands of tickets, so `GET /tickets` answers with one page and a
`nextCursor`. The recipe asks for open tickets, newest first, 50 to a page, and sends the cursor
back until it comes back empty. A cursor is a bookmark for this exact query, so the recipe passes
it on untouched, with the same filters.

If the API says the recipe is going too fast (`429`), it waits as long as the `Retry-After`
header says and tries once more. [Pagination and rate limits](https://docs.bydawn.com/developers/pagination-and-rate-limits/)
explains both in full.

The whole recipe:

**TypeScript** (`list-tickets.ts`):

```ts
// List tickets: page through every open ticket in a project, newest first.
// Built-in `fetch` only. The types come from `saw-api.d.ts`, which you generate from the public
// spec (see the Quickstart); without TypeScript, drop the type annotations.
//
//   SAW_API_KEY=saw_live_… node list-tickets.ts            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… node list-tickets.ts
import type { components } from './saw-api';

type Schemas = components['schemas'];
type ErrorSlug = Schemas['ApiErrorSlug'] | Schemas['AccountsApiErrorSlug'];
type Envelope<T> = { ok: true; data: T } | Schemas['ApiError'] | Schemas['AccountsApiError'];

const apiKey = process.env.SAW_API_KEY;
if (!apiKey) throw new Error('Set SAW_API_KEY in the environment.');
const ACCOUNTS_URL = process.env.SAW_ACCOUNTS_URL ?? 'https://accounts.dev.sawrun.com/api/v1';
const WORKSPACE_URL = process.env.SAW_WORKSPACE_URL ?? 'https://workspace.dev.sawrun.com/api/v1';

/** A failed call. Branch on `error`, the stable slug, never on the message. */
class ApiError extends Error {
  status: number;
  error: ErrorSlug;
  /** Seconds to wait before retrying, from the Retry-After header (1 if absent). */
  retryAfter: number;
  constructor(status: number, error: ErrorSlug, message?: string, retryAfter = 1) {
    super(`${status} ${error}${message ? ` — ${message}` : ''}`);
    this.status = status;
    this.error = error;
    this.retryAfter = retryAfter;
  }
}

// Sends a request and returns the envelope's `data`. Every response is {"ok": true, "data": …}
// or {"ok": false, "error": "<slug>", "message": …}; a failure throws an ApiError.
async function call<T>(url: string, { method = 'GET', body, token }: { method?: string; body?: unknown; token?: string } = {}): Promise<T> {
  const res = await fetch(url, {
    method,
    headers: {
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const envelope = (await res.json()) as Envelope<T>;
  if (envelope.ok) return envelope.data;
  throw new ApiError(res.status, envelope.error, envelope.message, Number(res.headers.get('Retry-After')) || 1);
}

// The access token is a JWT; its `org_id` claim is the organization the API key belongs to.
function orgIdOf(accessToken: string): string {
  return JSON.parse(atob(accessToken.split('.')[1]!.replace(/-/g, '+').replace(/_/g, '/'))).org_id;
}

// 1. Exchange the API key for a short-lived access token. Every call after this goes to your
// organization's URL with the token as a Bearer header.
const exchange: Schemas['ApiKeyExchange'] = { apiKey };
const { accessToken: token } = await call<Schemas['AccessTokenData']['data']>(`${ACCOUNTS_URL}/auth/token/api-key`, { method: 'POST', body: exchange });
const api = `${WORKSPACE_URL}/orgs/${orgIdOf(token)}`;

// 2. Choose the project: SAW_PROJECT_ID if you set it, or else your organization's first one.
const projectId = process.env.SAW_PROJECT_ID ?? (await call<Schemas['Project'][]>(`${api}/projects`, { token }))[0]?.id;
if (!projectId) throw new Error('This organization has no projects yet.');

// 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.
let cursor: string | null | undefined;
let listed = 0;
do {
  // URLSearchParams escapes each value; `nextCursor` is opaque and may hold + / = &.
  const query = new URLSearchParams({ projectId, open: 'true', sort: 'created', direction: 'desc', limit: '50' });
  if (cursor) query.set('cursor', cursor);
  const page = await getWithRetry<Schemas['TicketPage']>(`${api}/tickets?${query}`);

  if (listed === 0 && page.total !== undefined) console.log(`${page.total} open ticket(s):`);
  for (const ticket of page.items) {
    console.log(`  ${ticket.identifier.padEnd(10)} ${ticket.priority.padEnd(11)} ${ticket.title}`);
  }
  listed += page.items.length;
  // `nextCursor` is null on the last page.
  cursor = page.nextCursor;
} while (cursor);

console.log(`Listed ${listed} ticket(s).`);

// Helpers: the functions and types the steps above use.
// A 429 means slow down: wait as long as Retry-After says, then try once more.
async function getWithRetry<T>(url: string): Promise<T> {
  try {
    return await call<T>(url, { token });
  } catch (err) {
    if (!(err instanceof ApiError && err.status === 429)) throw err;
    await new Promise((resolve) => setTimeout(resolve, Math.min(err.retryAfter, 60) * 1000));
    return call<T>(url, { token });
  }
}
```

Run it:

```sh
SAW_API_KEY=saw_live_… node --experimental-strip-types list-tickets.ts
```

**Python** (`list_tickets.py`):

```python
# List tickets: page through every open ticket in a project, newest first.
# Standard library only (urllib and json), Python 3.9 or later.
#
#   SAW_API_KEY=saw_live_… python3 list_tickets.py            (the first project)
#   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… python3 list_tickets.py
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

API_KEY = os.environ.get("SAW_API_KEY") or sys.exit("Set SAW_API_KEY in the environment.")
ACCOUNTS_URL = os.environ.get("SAW_ACCOUNTS_URL", "https://accounts.dev.sawrun.com/api/v1")
WORKSPACE_URL = os.environ.get("SAW_WORKSPACE_URL", "https://workspace.dev.sawrun.com/api/v1")

class ApiError(Exception):
    """A failed call. Branch on `error`, the stable slug, never on the message."""

    def __init__(self, status, error, message=None, retry_after=1):
        super().__init__(f"{status} {error}" + (f" — {message}" if message else ""))
        self.status = status
        self.error = error
        self.retry_after = retry_after

def call(url, method="GET", body=None, token=None):
    """Send a request and return the envelope's `data`. Every response is {"ok": true, "data": …}
    or {"ok": false, "error": "<slug>", "message": …}; a failure raises ApiError."""
    headers = {"Content-Type": "application/json"} if body is not None else {}
    if token:
        headers["Authorization"] = f"Bearer {token}"
    data = json.dumps(body).encode() if body is not None else None
    try:
        with urllib.request.urlopen(urllib.request.Request(url, data, headers, method=method)) as response:
            return json.load(response)["data"]
    except urllib.error.HTTPError as failure:
        envelope = json.load(failure)
        retry_after = int(failure.headers.get("Retry-After") or 1)
        raise ApiError(failure.code, envelope["error"], envelope.get("message"), retry_after) from None

def org_id_of(access_token):
    """The access token is a JWT; its `org_id` claim is the organization the API key belongs to."""
    payload = access_token.split(".")[1]
    return json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))["org_id"]

# 1. Exchange the API key for a short-lived access token. Every call after this goes to your
# organization's URL with the token as a Bearer header.
token = call(f"{ACCOUNTS_URL}/auth/token/api-key", "POST", {"apiKey": API_KEY})["accessToken"]
api = f"{WORKSPACE_URL}/orgs/{org_id_of(token)}"

# 2. Choose the project: SAW_PROJECT_ID if you set it, or else your organization's first one.
project_id = os.environ.get("SAW_PROJECT_ID")
if not project_id:
    projects = call(f"{api}/projects", token=token)
    project_id = projects[0]["id"] if projects else sys.exit("This organization has no projects yet.")

# Helpers: the functions and types the steps above use.
def get_with_retry(url):
    """A 429 means slow down: wait as long as Retry-After says, then try once more."""
    try:
        return call(url, token=token)
    except ApiError as err:
        if err.status != 429:
            raise
        time.sleep(min(err.retry_after, 60))
        return call(url, token=token)

# 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.
cursor = None
listed = 0
while True:
    # urlencode escapes each value; `nextCursor` is opaque and may hold + / = &.
    query = {"projectId": project_id, "open": "true", "sort": "created", "direction": "desc", "limit": 50}
    if cursor:
        query["cursor"] = cursor
    page = get_with_retry(f"{api}/tickets?{urllib.parse.urlencode(query)}")

    if listed == 0 and page.get("total") is not None:
        print(f"{page['total']} open ticket(s):")
    for ticket in page["items"]:
        print(f"  {ticket['identifier'].ljust(10)} {ticket['priority'].ljust(11)} {ticket['title']}")
    listed += len(page["items"])
    # `nextCursor` is null on the last page.
    cursor = page.get("nextCursor")
    if not cursor:
        break

print(f"Listed {listed} ticket(s).")
```

Run it:

```sh
SAW_API_KEY=saw_live_… python3 list_tickets.py
```

**Java** (`ListTickets.java`):

```java
// List tickets: page through every open ticket in a project, newest first.
// java.net.http for HTTP and Jackson for JSON. JBang runs this file and fetches Jackson; in a
// Maven or Gradle project, add com.fasterxml.jackson.core:jackson-databind and drop the `//` lines.
//
//   SAW_API_KEY=saw_live_… jbang ListTickets.java            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… jbang ListTickets.java
//JAVA 17+
//COMPILE_OPTIONS -encoding UTF-8
//DEPS com.fasterxml.jackson.core:jackson-databind:2.19.0

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;

public class ListTickets {
  /** Every response is an envelope: {"ok": true, "data": …} or {"ok": false, "error": "<slug>", "message": …}. */
  record Envelope<T>(boolean ok, T data, String error, String message) {}
  record AccessToken(String accessToken, int expiresInSeconds) {}
  record Project(String id, String name) {}
  record Ticket(String id, String identifier, String title, String priority, String description) {}
  record TicketPage(List<Ticket> items, String nextCursor, Integer total) {}

  /** A failed call. Branch on error(), the stable slug, never on the message. */
  static class ApiException extends RuntimeException {
    final int status;
    final String error;
    /** The Retry-After header, in seconds (1 if absent). */
    final int retryAfter;

    ApiException(int status, String error, String message, int retryAfter) {
      super(status + " " + error + (message == null ? "" : " — " + message));
      this.status = status;
      this.error = error;
      this.retryAfter = retryAfter;
    }
  }

  static final HttpClient http = HttpClient.newHttpClient();
  // Jackson maps the JSON onto the records above and skips the fields they don't name.
  static final ObjectMapper json = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

  public static void main(String[] args) throws Exception {
    String apiKey = System.getenv("SAW_API_KEY");
    if (apiKey == null || apiKey.isEmpty()) throw new IllegalStateException("Set SAW_API_KEY in the environment.");
    String accountsUrl = env("SAW_ACCOUNTS_URL", "https://accounts.dev.sawrun.com/api/v1");
    String workspaceUrl = env("SAW_WORKSPACE_URL", "https://workspace.dev.sawrun.com/api/v1");

    // 1. Exchange the API key for a short-lived access token. Every call after this goes to your
    // organization's URL with the token as a Bearer header.
    String token = call("POST", accountsUrl + "/auth/token/api-key", null, Map.of("apiKey", apiKey), new TypeReference<Envelope<AccessToken>>() {}).accessToken();
    String api = workspaceUrl + "/orgs/" + orgIdOf(token);

    // 2. Choose the project: SAW_PROJECT_ID if you set it, or else your organization's first one.
    String projectId = System.getenv("SAW_PROJECT_ID");
    if (projectId == null || projectId.isEmpty()) {
      List<Project> projects = call("GET", api + "/projects", token, null, new TypeReference<Envelope<List<Project>>>() {});
      if (projects.isEmpty()) throw new IllegalStateException("This organization has no projects yet.");
      projectId = projects.get(0).id();
    }

    // 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.
    String cursor = null;
    int listed = 0;
    do {
      String query = "projectId=" + encode(projectId) + "&open=true&sort=created&direction=desc&limit=50";
      // `nextCursor` is opaque and may hold + / = &, so encode it too.
      if (cursor != null) query += "&cursor=" + encode(cursor);
      TicketPage page = getWithRetry(api + "/tickets?" + query, token, new TypeReference<Envelope<TicketPage>>() {});

      if (listed == 0 && page.total() != null) System.out.println(page.total() + " open ticket(s):");
      for (Ticket ticket : page.items()) {
        System.out.println(String.format("  %-10s %-11s %s", ticket.identifier(), ticket.priority(), ticket.title()));
      }
      listed += page.items().size();
      // `nextCursor` is null on the last page.
      cursor = page.nextCursor();
    } while (cursor != null);

    System.out.println("Listed " + listed + " ticket(s).");
  }

  /** A 429 means slow down: wait as long as Retry-After says, then try once more. */
  static <T> T getWithRetry(String url, String token, TypeReference<Envelope<T>> type) throws Exception {
    try {
      return call("GET", url, token, null, type);
    } catch (ApiException e) {
      if (e.status != 429) throw e;
      Thread.sleep(Math.min(e.retryAfter, 60) * 1000L);
      return call("GET", url, token, null, type);
    }
  }

  static String encode(String value) {
    return URLEncoder.encode(value, StandardCharsets.UTF_8);
  }

  /** Sends a request and returns the envelope's data. A failure throws an ApiException. */
  static <T> T call(String method, String url, String token, Object body, TypeReference<Envelope<T>> type) throws Exception {
    HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url));
    if (token != null) request.header("Authorization", "Bearer " + token);
    if (body == null) {
      request.method(method, HttpRequest.BodyPublishers.noBody());
    } else {
      request.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)));
    }
    HttpResponse<String> response = http.send(request.build(), HttpResponse.BodyHandlers.ofString());
    Envelope<T> envelope = json.readValue(response.body(), type);
    if (envelope.ok()) return envelope.data();
    int retryAfter = response.headers().firstValue("Retry-After").map(Integer::parseInt).orElse(1);
    throw new ApiException(response.statusCode(), envelope.error(), envelope.message(), retryAfter);
  }

  /** The access token is a JWT; its `org_id` claim is the organization the API key belongs to. */
  static String orgIdOf(String token) throws Exception {
    return json.readTree(Base64.getUrlDecoder().decode(token.split("\\.")[1])).path("org_id").asText();
  }

  static String env(String name, String fallback) {
    String value = System.getenv(name);
    return value == null || value.isEmpty() ? fallback : value;
  }
}
```

Run it:

```sh
SAW_API_KEY=saw_live_… jbang ListTickets.java
```

**C#** (`list-tickets.cs`):

```csharp
// List tickets: page through every open ticket in a project, newest first.
// Plain HttpClient and System.Net.Http.Json, both part of .NET: no NuGet packages.
//
//   SAW_API_KEY=saw_live_… dotnet run list-tickets.cs            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… dotnet run list-tickets.cs
//
// `dotnet run <file>.cs` builds for Native AOT by default, which turns off reflection-based JSON;
// the `#:` line below turns AOT off. In a console project, leave that line out and paste the rest
// into Program.cs.
#:property PublishAot=false
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var apiKey = Environment.GetEnvironmentVariable("SAW_API_KEY");
if (string.IsNullOrEmpty(apiKey)) throw new InvalidOperationException("Set SAW_API_KEY in the environment.");
var accountsUrl = Environment.GetEnvironmentVariable("SAW_ACCOUNTS_URL") ?? "https://accounts.dev.sawrun.com/api/v1";
var workspaceUrl = Environment.GetEnvironmentVariable("SAW_WORKSPACE_URL") ?? "https://workspace.dev.sawrun.com/api/v1";

// 1. Exchange the API key for a short-lived access token.
using var accounts = new HttpClient();
var token = await ReadAsync<AccessTokenData>(await accounts.PostAsJsonAsync($"{accountsUrl}/auth/token/api-key", new { apiKey }));

// Every call goes to your organization's URL with the token as a Bearer header.
using var http = new HttpClient { BaseAddress = new Uri($"{workspaceUrl}/orgs/{OrgIdOf(token.AccessToken)}/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);

// 2. Choose the project: SAW_PROJECT_ID if you set it, or else your organization's first one.
var projectId = Environment.GetEnvironmentVariable("SAW_PROJECT_ID")
    ?? (await ReadAsync<List<Project>>(await http.GetAsync("projects"))).FirstOrDefault()?.Id
    ?? throw new InvalidOperationException("This organization has no projects yet.");

// 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.
string? cursor = null;
var listed = 0;
do
{
    var query = $"projectId={Uri.EscapeDataString(projectId)}&open=true&sort=created&direction=desc&limit=50";
    // `nextCursor` is opaque and may hold characters such as + / = &, so escape it too.
    if (cursor is not null) query += $"&cursor={Uri.EscapeDataString(cursor)}";
    var page = await ReadAsync<TicketPage>(await GetWithRetryAsync($"tickets?{query}"));

    if (listed == 0 && page.Total is int total) Console.WriteLine($"{total} open ticket(s):");
    foreach (var ticket in page.Items)
    {
        Console.WriteLine($"  {ticket.Identifier,-10} {ticket.Priority,-11} {ticket.Title}");
    }
    listed += page.Items.Count;
    // `nextCursor` is null on the last page.
    cursor = page.NextCursor;
} while (cursor is not null);

Console.WriteLine($"Listed {listed} ticket(s).");

// Helpers: the functions and types the steps above use.
// A 429 means slow down: wait as long as Retry-After says, then try once more.
async Task<HttpResponseMessage> GetWithRetryAsync(string uri)
{
    var response = await http.GetAsync(uri);
    if (response.StatusCode != HttpStatusCode.TooManyRequests) return response;
    var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
    await Task.Delay(wait < TimeSpan.FromSeconds(60) ? wait : TimeSpan.FromSeconds(60));
    return await http.GetAsync(uri);
}

// Every response is an envelope: {"ok": true, "data": …} or {"ok": false, "error": "<slug>", "message": …}.
static async Task<T> ReadAsync<T>(HttpResponseMessage response)
{
    var envelope = await response.Content.ReadFromJsonAsync<Envelope<T>>();
    if (envelope is { Ok: true, Data: not null }) return envelope.Data;
    throw new ApiException(response.StatusCode, envelope?.Error ?? "internal_error", envelope?.Message);
}

// The access token is a JWT; its `org_id` claim is the organization the API key belongs to.
static string OrgIdOf(string accessToken)
{
    var payload = accessToken.Split('.')[1].Replace('-', '+').Replace('_', '/');
    var json = Convert.FromBase64String(payload.PadRight(payload.Length + (4 - payload.Length % 4) % 4, '='));
    return JsonDocument.Parse(json).RootElement.GetProperty("org_id").GetString()!;
}

record Envelope<T>(bool Ok, T? Data, string? Error, string? Message);
record AccessTokenData(string AccessToken, int ExpiresInSeconds);
record Project(string Id, string Name);
record Ticket(string Id, string Identifier, string Title, string Priority, string? Description);
record TicketPage(List<Ticket> Items, string? NextCursor, int? Total);

/// <summary>A failed call. Branch on <see cref="Error"/>, the stable slug, never on the message.</summary>
class ApiException(HttpStatusCode status, string error, string? message)
    : Exception($"{(int)status} {error}{(message is null ? "" : $" — {message}")}")
{
    public HttpStatusCode Status { get; } = status;
    public string Error { get; } = error;
}
```

Run it:

```sh
SAW_API_KEY=saw_live_… dotnet run list-tickets.cs
```

**Go** (`list_tickets.go`):

```go
// List tickets: page through every open ticket in a project, newest first.
// Standard library only (net/http and encoding/json).
//
//	SAW_API_KEY=saw_live_… go run list_tickets.go            (the first project)
//	SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… go run list_tickets.go
package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strconv"
	"strings"
	"time"
)

// envelope is every response: {"ok": true, "data": …} or {"ok": false, "error": "<slug>", "message": …}.
type envelope[T any] struct {
	OK      bool   `json:"ok"`
	Data    T      `json:"data"`
	Error   string `json:"error"`
	Message string `json:"message"`
}

// apiError is a failed call. Branch on Slug, the stable error slug, never on Message.
type apiError struct {
	Status  int
	Slug    string
	Message string
	// RetryAfter is the Retry-After header, in seconds (1 if absent).
	RetryAfter int
}

func (e *apiError) Error() string {
	if e.Message == "" {
		return fmt.Sprintf("%d %s", e.Status, e.Slug)
	}
	return fmt.Sprintf("%d %s — %s", e.Status, e.Slug, e.Message)
}

type accessToken struct {
	AccessToken      string `json:"accessToken"`
	ExpiresInSeconds int    `json:"expiresInSeconds"`
}

type project struct {
	ID   string `json:"id"`
	Name string `json:"name"`
}

type ticket struct {
	ID          string  `json:"id"`
	Identifier  string  `json:"identifier"`
	Title       string  `json:"title"`
	Description *string `json:"description"`
	Priority    string  `json:"priority"`
}

type ticketPage struct {
	Items      []ticket `json:"items"`
	NextCursor *string  `json:"nextCursor"`
	Total      *int     `json:"total"`
}

func main() {
	apiKey := os.Getenv("SAW_API_KEY")
	if apiKey == "" {
		fail("Set SAW_API_KEY in the environment.")
	}
	accountsURL := env("SAW_ACCOUNTS_URL", "https://accounts.dev.sawrun.com/api/v1")
	workspaceURL := env("SAW_WORKSPACE_URL", "https://workspace.dev.sawrun.com/api/v1")

	// 1. Exchange the API key for a short-lived access token. Every call after this goes to your
	// organization's URL with the token as a Bearer header.
	exchange, err := call[accessToken](http.MethodPost, accountsURL+"/auth/token/api-key", "", map[string]string{"apiKey": apiKey})
	check(err)
	token := exchange.AccessToken
	api := workspaceURL + "/orgs/" + orgIDOf(token)

	// 2. Choose the project: SAW_PROJECT_ID if you set it, or else your organization's first one.
	projectID := os.Getenv("SAW_PROJECT_ID")
	if projectID == "" {
		projects, err := call[[]project](http.MethodGet, api+"/projects", token, nil)
		check(err)
		if len(projects) == 0 {
			fail("This organization has no projects yet.")
		}
		projectID = projects[0].ID
	}

	// 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.
	// url.Values escapes each value; `nextCursor` is opaque and may hold + / = &.
	query := url.Values{
		"projectId": {projectID}, "open": {"true"}, "sort": {"created"}, "direction": {"desc"}, "limit": {"50"},
	}
	listed := 0
	for {
		page, err := getWithRetry[ticketPage](api+"/tickets?"+query.Encode(), token)
		check(err)

		if listed == 0 && page.Total != nil {
			fmt.Printf("%d open ticket(s):\n", *page.Total)
		}
		for _, t := range page.Items {
			fmt.Printf("  %-10s %-11s %s\n", t.Identifier, t.Priority, t.Title)
		}
		listed += len(page.Items)
		// `nextCursor` is null on the last page.
		if page.NextCursor == nil || *page.NextCursor == "" {
			break
		}
		query.Set("cursor", *page.NextCursor)
	}

	fmt.Printf("Listed %d ticket(s).\n", listed)
}

// getWithRetry GETs url. A 429 means slow down: wait as long as Retry-After says, then try once more.
func getWithRetry[T any](url, token string) (T, error) {
	data, err := call[T](http.MethodGet, url, token, nil)
	var apiErr *apiError
	if !errors.As(err, &apiErr) || apiErr.Status != http.StatusTooManyRequests {
		return data, err
	}
	time.Sleep(time.Duration(min(apiErr.RetryAfter, 60)) * time.Second)
	return call[T](http.MethodGet, url, token, nil)
}

// call sends a request and decodes the envelope's data into T. A failure is an *apiError.
func call[T any](method, url, token string, body any) (T, error) {
	var data T
	var reader io.Reader
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			return data, err
		}
		reader = bytes.NewReader(encoded)
	}
	req, err := http.NewRequest(method, url, reader)
	if err != nil {
		return data, err
	}
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return data, err
	}
	defer res.Body.Close()
	var out envelope[T]
	if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
		return data, fmt.Errorf("%s %s answered %s with no JSON envelope", method, url, res.Status)
	}
	if !out.OK {
		retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After"))
		if err != nil || retryAfter < 1 {
			retryAfter = 1
		}
		return data, &apiError{Status: res.StatusCode, Slug: out.Error, Message: out.Message, RetryAfter: retryAfter}
	}
	return out.Data, nil
}

// orgIDOf reads the organization the API key belongs to: the access token is a JWT, and its
// `org_id` claim names it.
func orgIDOf(token string) string {
	parts := strings.Split(token, ".")
	if len(parts) < 2 {
		fail("The access token is not a JWT.")
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	check(err)
	var claims struct {
		OrgID string `json:"org_id"`
	}
	check(json.Unmarshal(payload, &claims))
	return claims.OrgID
}

func env(name, fallback string) string {
	if value := os.Getenv(name); value != "" {
		return value
	}
	return fallback
}

func check(err error) {
	if err != nil {
		fail(err.Error())
	}
}

func fail(message string) {
	fmt.Fprintln(os.Stderr, message)
	os.Exit(1)
}
```

Run it:

```sh
SAW_API_KEY=saw_live_… go run list_tickets.go
```

**cURL** (`list-tickets.sh`):

```bash
#!/usr/bin/env bash
# List tickets: page through every open ticket in a project, newest first.
# Needs curl and jq: jq builds the request bodies and reads the responses.
#
#   SAW_API_KEY=saw_live_… bash list-tickets.sh            (the first project)
#   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… bash list-tickets.sh
set -euo pipefail

: "${SAW_API_KEY:?Set SAW_API_KEY in the environment.}"
ACCOUNTS_URL="${SAW_ACCOUNTS_URL:-https://accounts.dev.sawrun.com/api/v1}"
WORKSPACE_URL="${SAW_WORKSPACE_URL:-https://workspace.dev.sawrun.com/api/v1}"

# Every response is {"ok": true, "data": …} or {"ok": false, "error": "<slug>", "message": …}.
# Piped through this jq filter, a success prints its `data`; a failure prints the envelope on
# stderr and stops the script.
DATA='if .ok then .data else halt_error end'

# 1. Exchange the API key for a short-lived access token. jq reads the key from the environment, so
# it never appears on a command line.
token=$(jq -n '{apiKey: env.SAW_API_KEY}' |
  curl -sS "$ACCOUNTS_URL/auth/token/api-key" -H 'Content-Type: application/json' --data @- |
  jq -r "$DATA | .accessToken")

# The token is a JWT; its `org_id` claim is the organization the key belongs to. Every call
# after this goes to that organization's URL with the token as a Bearer header.
org_id=$(jq -rR 'split(".")[1] | gsub("-"; "+") | gsub("_"; "/") | . + ("=" * ((4 - length % 4) % 4))
  | @base64d | fromjson | .org_id' <<<"$token")
api="$WORKSPACE_URL/orgs/$org_id"

# 2. Choose the project: SAW_PROJECT_ID if you set it, or else your organization's first one.
project_id=${SAW_PROJECT_ID:-}
if [ -z "$project_id" ]; then
  project_id=$(curl -sS "$api/projects" -H "Authorization: Bearer $token" | jq -r "$DATA | .[0].id // empty")
fi
if [ -z "$project_id" ]; then
  echo 'This organization has no projects yet.' >&2
  exit 1
fi

# 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.
page=$(mktemp)
trap 'rm -f "$page"' EXIT

cursor=''
listed=0
while :; do
  # -G sends the --data fields as the query string; --data-urlencode escapes the IDs and the
  # cursor, which is opaque and may hold + / = &. On a 429, --retry waits as long as Retry-After
  # says and tries once more, writing over the file.
  curl -sS --retry 1 -o "$page" -G "$api/tickets" -H "Authorization: Bearer $token" \
    --data-urlencode "projectId=$project_id" -d open=true -d sort=created -d direction=desc -d limit=50 \
    ${cursor:+--data-urlencode "cursor=$cursor"}
  jq "$DATA" "$page" >/dev/null

  if [ "$listed" = 0 ] && [ "$(jq '.data.total != null' "$page")" = true ]; then
    echo "$(jq .data.total "$page") open ticket(s):"
  fi
  jq -r 'def pad($n): . + " " * ($n - length);
    .data.items[] | "  \(.identifier | pad(10)) \(.priority | pad(11)) \(.title)"' "$page"
  listed=$((listed + $(jq '.data.items | length' "$page")))
  # `nextCursor` is null on the last page.
  cursor=$(jq -r '.data.nextCursor // empty' "$page")
  if [ -z "$cursor" ]; then break; fi
done

echo "Listed $listed ticket(s)."
```

Run it:

```sh
SAW_API_KEY=saw_live_… bash list-tickets.sh
```

What you'll see (from our test organization; your names and IDs will differ):

```text
3 open ticket(s):
  WEB-4      URGENT      Keyboard shortcuts for the board
  WEB-2      LOW         Dark mode for the board
  WEB-1      MEDIUM      Export the board as CSV
Listed 3 ticket(s).
```

## Run it in your browser

Run the TypeScript recipe from this page against your own organization. Your browser runs the
same code, with your key standing in for `SAW_API_KEY`. See
[Try it in your browser](https://docs.bydawn.com/developers/try-it/).

> A runner for this recipe, with your own credential, is on the [web version of this page](https://docs.bydawn.com/developers/recipes/list-tickets/).

## Filters

Narrow the list in the request, not in your code: it's faster, and it saves your rate limit.
`projectId` is required with a key. Everything else is optional, and filters combine:

- **Where it sits:** `statusId`, `open` (leave out the completed column) and `includeArchived`.
- **Planning:** `cycleId`, `epicId`, `milestoneId`, `releaseId` and `parentTicketId`. For
  `cycleId`, `epicId` and `parentTicketId`, `none` means "not in any". See
  [Group work into epics](https://docs.bydawn.com/developers/epics/) for the epic IDs a project has.
- **People:** `assigneeId`, and `teamId` for the team that owns the ticket (`none` for the tickets no team owns).
  See [Organize work by team](https://docs.bydawn.com/developers/organize-work-by-team/).
- **Text:** `q` searches the identifier, title and description.
- **Order:** `sort` (`manual`, `priority`, `created`, `updated` or `title`) and `direction`.

To find the IDs, use `GET /projects`, `/statuses?projectId=…`, `/cycles` and `/users`. The
[API reference](https://docs.bydawn.com/reference/) documents each one.

Source: https://docs.bydawn.com/developers/recipes/list-tickets/
