List and filter tickets
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.
Read down the steps. The code on the right follows along and highlights the lines each one explains.
Swap your key for an access token
Section titled “Swap your key for an access token”Every recipe starts this way, as the Quickstart explains.
// 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)}`;# 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)}"// 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);// 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);// 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.AccessTokenapi := workspaceURL + "/orgs/" + orgIDOf(token)Terminal window # 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"Choose the project
Section titled “Choose the project”An API key always lists tickets one project at a time, so the recipe needs a project ID.
GET /projectslists them if you need to look one up.// 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.');# 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.")// 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();}// 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.");// 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}Terminal window # 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" ]; thenproject_id=$(curl -sS "$api/projects" -H "Authorization: Bearer $token" | jq -r "$DATA | .[0].id // empty")fiif [ -z "$project_id" ]; thenecho 'This organization has no projects yet.' >&2exit 1fiRead the tickets a page at a time
Section titled “Read the tickets a page at a time”A project can hold thousands of tickets, so
GET /ticketsanswers with one page and anextCursor. 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 theRetry-Afterheader says and tries once more. Pagination and rate limits explains both in full.// 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).`);# 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.cursor = Nonelisted = 0while 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"] = cursorpage = 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:breakprint(f"Listed {listed} ticket(s).")// 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).");// 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).");// 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 := 0for {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)Terminal window # 3. Ask for open tickets a page at a time, newest first, and follow nextCursor to the end.page=$(mktemp)trap 'rm -f "$page"' EXITcursor=''listed=0while :; 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/nullif [ "$listed" = 0 ] && [ "$(jq '.data.total != null' "$page")" = true ]; thenecho "$(jq .data.total "$page") open ticket(s):"fijq -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; fidoneecho "Listed $listed ticket(s)."
Run it in your browser
Section titled “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.
Try it with your organization
How this worksConnect once and run this recipe live against the By Dawn API.
Your key stays in this tab and goes only to By Dawn. Use a read-only key in a test organization.
list-tickets.tsThe same code CI runs, in your browserlist-tickets.ts…Filters
Section titled “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) andincludeArchived. - Planning:
cycleId,epicId,milestoneId,releaseIdandparentTicketId. ForcycleId,epicIdandparentTicketId,nonemeans “not in any”. See Group work into epics for the epic IDs a project has. - People:
assigneeId, andteamIdfor the team that owns the ticket (nonefor the tickets no team owns). See Organize work by team. - Text:
qsearches the identifier, title and description. - Order:
sort(manual,priority,created,updatedortitle) anddirection.
To find the IDs, use GET /projects, /statuses?projectId=…, /cycles and /users. The
API reference documents each one.