Organize work by team
Use this when your integration needs to say whose work a ticket is: filing an alert for the team that owns the service, or reporting what each team has open. By the end you’ll know which team IDs a project accepts, how to file and move a ticket by team, and how to list a team’s tickets. The recipe does all three in every language.
How teams fit with projects
Section titled “How teams fit with projects”A project is where a ticket lives. It gives the ticket its identifier (SAW-1234), its board columns and its
cycles. A team is a group of people in your organization, and it owns work. Each project lists the teams that
work in it, and each ticket can have one owning team, its teamId.
These are separate on purpose, so several teams can share one project and one team can work across several projects.
Moving a ticket to another team changes only its teamId. Its identifier, column, cycle and labels stay as they are,
because the identifier is also in the ticket’s branch names and pull requests.
People create teams and attach them to projects in the app (see Teams). A project’s teams and a ticket’s
teamId name a team by its ID, team-…, so keep a team’s ID in your configuration, the way you keep a project’s. When
you need a team’s name, look it up in your organization’s list of teams.
Find the teams that work in a project
Section titled “Find the teams that work in a project”Read the project’s teams before you set a ticket’s team, because a ticket can only be owned by one of them.
GET /projects/{projectId}/teams lists them, oldest first:
{ "ok": true, "data": [ { "projectId": "project-3a86…", "teamId": "team-1f4e…", "isDefault": true, "createdBy": "user-6a1f…", "createdAt": "2026-10-14T10:02:11Z", "updatedAt": "2026-10-14T10:02:11Z" }, { "projectId": "project-3a86…", "teamId": "team-8c3a…", "isDefault": false, "createdBy": "user-6a1f…", "createdAt": "2026-10-15T08:41:37Z", "updatedAt": "2026-10-15T08:41:37Z" } ]}At most one of them is the project’s default team (isDefault: true). An empty list means no team works in the
project yet, so its tickets have no team. A key with Board — read only can read this list for any project it can see
(see Scopes).
Put names to team IDs
Section titled “Put names to team IDs”To show a team by name, or to find the ID of the team called “Mobile”, read your organization’s list of teams,
GET /orgs/{orgId}/teams. It’s served by the accounts host, like the key
exchange, and needs a signed-in member’s token: an API key gets 403 forbidden (see
What a key can’t do). {orgId} is the org_id claim of the token
(see Your organization ID).
GET https://accounts.dev.sawrun.com/api/v1/orgs/{orgId}/teamsIt lists every team, sorted by name. Each team’s id is the teamId that projects and tickets use. Here it is trimmed
to the fields this page uses:
{ "ok": true, "data": [ { "id": "team-8c3a…", "name": "Mobile", "slug": "mobile", "key": "MOB", "color": "#3F8FE0", "memberCount": 4, "callerRole": null, "archivedAt": null }, { "id": "team-1f4e…", "name": "Platform", "slug": "platform", "key": "PLAT", "color": null, "memberCount": 7, "callerRole": "lead", "archivedAt": null } ]}key is the short label the app shows on a team’s chips, and callerRole is your own role on the team, or null
when you’re not on it. Archived teams are left out unless you add ?includeArchived=true, which you need only to name
a team you stored before it was archived. The API reference lists every field.
File a ticket for a team
Section titled “File a ticket for a team”Send teamId with POST /tickets:
{ "projectId": "project-3a86…", "title": "Checkout returns 502 for EU cards", "teamId": "team-8c3a…" }The ticket you get back carries the same teamId. If you leave teamId out, By Dawn picks one:
- For a person’s token, the ticket goes to the creator’s own team, if that team works in the project. When they’re on several, it goes to the first by name.
- Otherwise, and always for an API key, it goes to the project’s default team. A key isn’t on any team.
- With no default team, the ticket has no team.
Send "teamId": null to file a ticket with no team even when the project has a default.
A team that doesn’t work in the project is refused with 422, and nothing is created:
{ "ok": false, "error": "invalid_team", "message": "the team is not attached to this project — attach it first" }Branch on the invalid_team slug, never on the message (see Responses and errors).
An archived team is refused the same way.
Move a ticket to another team
Section titled “Move a ticket to another team”Send the new teamId with PATCH /tickets/{ticketId}, or null to take the ticket off its team:
{ "teamId": "team-1f4e…" }Only the team changes. The same invalid_team rule applies: the new team must work in the ticket’s project.
List a team’s tickets
Section titled “List a team’s tickets”Filter GET /tickets by teamId, in one project at a time:
GET /tickets?projectId=project-3a86…&teamId=team-8c3a…- Repeat
teamIdto list the tickets of any of several teams, up to 50. teamId=nonelists the tickets no team owns, which is useful for finding work nobody has picked up.- The rest of the list’s filters, paging and sorting work as usual (see Pagination and rate limits).
With a signed-in member’s token you can also list a team’s tickets across every project, with
GET /teams/{teamId}/tickets. It takes the same paging and sorting, and leaves out the restricted projects you can’t
read. A status ID names one project’s column, so to narrow by column across projects, use the roles every project’s
columns share: open=true&excludeStatusRole=backlog is the team’s work in progress. An API key can’t use this
route, because a key reads one project at a time, so with a key, list each project in turn.
Recipe: file, list and clear a team’s ticket
Section titled “Recipe: file, list and clear a team’s ticket”The recipe puts the calls above together. It files a ticket for one of the project’s teams, finds it among the team’s
tickets, then takes it off the team. It needs a Board — read & write key (see
Scopes). 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”A ticket’s team has to work in the ticket’s project, so the recipe starts from a project, the same way Create a ticket does.
// 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 1fiFind the project’s teams
Section titled “Find the project’s teams”GET /projects/{projectId}/teamslists the teams attached to the project, oldest first. The recipe takes the project’s default team, or the oldest one if there’s no default. Your integration would usually take a team ID it keeps in its configuration and check that it’s in this list.If the list is empty, no team works in the project yet. The recipe says so and stops, because there’s no team a ticket could have. Attach one in the project’s settings in the app (see Teams).
// 3. List the teams that work in the project, oldest first. A ticket can only be owned by one of// them, so take the project's default team, or else the oldest.const teams = await call<Schemas['ProjectTeam'][]>(`${api}/projects/${encodeURIComponent(projectId)}/teams`, { token });const team = teams.find((t) => t.isDefault) ?? teams[0];if (!team) {console.log(`No team works in ${projectId} yet. Attach one in the project's settings in the app, then run this again.`);} else {console.log(`${projectId} has ${teams.length} team(s):`);for (const t of teams) console.log(` ${t.teamId}${t.isDefault ? ' (default)' : ''}`);# 3. List the teams that work in the project, oldest first. A ticket can only be owned by one of# them, so take the project's default team, or else the oldest.teams = call(f"{api}/projects/{urllib.parse.quote(project_id, safe='')}/teams", token=token)team = next((t for t in teams if t["isDefault"]), teams[0] if teams else None)if team is None:print(f"No team works in {project_id} yet. Attach one in the project's settings in the app, then run this again.")sys.exit()print(f"{project_id} has {len(teams)} team(s):")for t in teams:print(f" {t['teamId']}" + (" (default)" if t["isDefault"] else ""))// 3. List the teams that work in the project, oldest first. A ticket can only be owned by one of// them, so take the project's default team, or else the oldest.List<ProjectTeam> teams = call("GET", api + "/projects/" + encode(projectId) + "/teams", token, null, new TypeReference<Envelope<List<ProjectTeam>>>() {});ProjectTeam team = teams.stream().filter(ProjectTeam::isDefault).findFirst().orElse(teams.isEmpty() ? null : teams.get(0));if (team == null) {System.out.println("No team works in " + projectId + " yet. Attach one in the project's settings in the app, then run this again.");return;}System.out.println(projectId + " has " + teams.size() + " team(s):");for (ProjectTeam t : teams) System.out.println(" " + t.teamId() + (t.isDefault() ? " (default)" : ""));// 3. List the teams that work in the project, oldest first. A ticket can only be owned by one of// them, so take the project's default team, or else the oldest.var teams = await ReadAsync<List<ProjectTeam>>(await http.GetAsync($"projects/{Uri.EscapeDataString(projectId)}/teams"));var team = teams.FirstOrDefault(t => t.IsDefault) ?? teams.FirstOrDefault();if (team is null){Console.WriteLine($"No team works in {projectId} yet. Attach one in the project's settings in the app, then run this again.");return;}Console.WriteLine($"{projectId} has {teams.Count} team(s):");foreach (var t in teams) Console.WriteLine($" {t.TeamId}{(t.IsDefault ? " (default)" : "")}");// 3. List the teams that work in the project, oldest first. A ticket can only be owned by one of// them, so take the project's default team, or else the oldest.teams, err := call[[]projectTeam](http.MethodGet, api+"/projects/"+url.PathEscape(projectID)+"/teams", token, nil)check(err)if len(teams) == 0 {fmt.Printf("No team works in %s yet. Attach one in the project's settings in the app, then run this again.\n", projectID)return}team := teams[0]for _, t := range teams {if t.IsDefault {team = tbreak}}fmt.Printf("%s has %d team(s):\n", projectID, len(teams))for _, t := range teams {if t.IsDefault {fmt.Printf(" %s (default)\n", t.TeamID)} else {fmt.Printf(" %s\n", t.TeamID)}}Terminal window # 3. List the teams that work in the project, oldest first. A ticket can only be owned by one of# them, so take the project's default team, or else the oldest.teams=$(curl -sS "$api/projects/$(jq -rn --arg id "$project_id" '$id | @uri')/teams" -H "Authorization: Bearer $token" | jq "$DATA")team_id=$(jq -r '(map(select(.isDefault)) + .)[0].teamId // empty' <<<"$teams")if [ -z "$team_id" ]; thenecho "No team works in $project_id yet. Attach one in the project's settings in the app, then run this again."exit 0fiecho "$project_id has $(jq length <<<"$teams") team(s):"jq -r '.[] | " \(.teamId)\(if .isDefault then " (default)" else "" end)"' <<<"$teams"File a ticket for the team
Section titled “File a ticket for the team”POST /ticketswith ateamIdfiles the ticket for that team, and the ticket comes back with the sameteamId. The recipe checks that it did. A team that doesn’t work in the project would be refused withinvalid_team.// 4. File a ticket owned by the team. Without `teamId`, a key's ticket goes to the default team.const create: Schemas['TicketCreate'] = { projectId, title: 'Filed for a team by the team-tickets recipe', teamId: team.teamId };const ticket = await call<Schemas['Ticket']>(`${api}/tickets`, { method: 'POST', body: create, token });if (ticket.teamId !== team.teamId) throw new Error(`${ticket.identifier} came back owned by ${ticket.teamId}, not ${team.teamId}.`);console.log(`Filed ${ticket.identifier} for ${team.teamId}: ${ticket.title}`);# 4. File a ticket owned by the team. Without `teamId`, a key's ticket goes to the default team.ticket = call(f"{api}/tickets","POST",{"projectId": project_id, "title": "Filed for a team by the team-tickets recipe", "teamId": team["teamId"]},token,)if ticket.get("teamId") != team["teamId"]:sys.exit(f"{ticket['identifier']} came back owned by {ticket.get('teamId')}, not {team['teamId']}.")print(f"Filed {ticket['identifier']} for {team['teamId']}: {ticket['title']}")// 4. File a ticket owned by the team. Without `teamId`, a key's ticket goes to the default team.Ticket ticket = call("POST", api + "/tickets", token, Map.of("projectId", projectId,"title", "Filed for a team by the team-tickets recipe","teamId", team.teamId()), new TypeReference<Envelope<Ticket>>() {});if (!team.teamId().equals(ticket.teamId())) {throw new IllegalStateException(ticket.identifier() + " came back owned by " + ticket.teamId() + ", not " + team.teamId() + ".");}System.out.println("Filed " + ticket.identifier() + " for " + team.teamId() + ": " + ticket.title());// 4. File a ticket owned by the team. Without `teamId`, a key's ticket goes to the default team.var ticket = await ReadAsync<Ticket>(await http.PostAsJsonAsync("tickets", new{projectId,title = "Filed for a team by the team-tickets recipe",teamId = team.TeamId,}));if (ticket.TeamId != team.TeamId) throw new InvalidOperationException($"{ticket.Identifier} came back owned by {ticket.TeamId}, not {team.TeamId}.");Console.WriteLine($"Filed {ticket.Identifier} for {team.TeamId}: {ticket.Title}");// 4. File a ticket owned by the team. Without `teamId`, a key's ticket goes to the default team.created, err := call[ticket](http.MethodPost, api+"/tickets", token, map[string]string{"projectId": projectID,"title": "Filed for a team by the team-tickets recipe","teamId": team.TeamID,})check(err)if created.TeamID == nil || *created.TeamID != team.TeamID {fail(fmt.Sprintf("%s came back owned by %s, not %s.", created.Identifier, deref(created.TeamID), team.TeamID))}fmt.Printf("Filed %s for %s: %s\n", created.Identifier, team.TeamID, created.Title)Terminal window # 4. File a ticket owned by the team. Without `teamId`, a key's ticket goes to the default team.ticket=$(jq -n --arg projectId "$project_id" --arg teamId "$team_id" \'{projectId: $projectId, title: "Filed for a team by the team-tickets recipe", teamId: $teamId}' |curl -sS -X POST "$api/tickets" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' --data @- |jq "$DATA")if [ "$(jq -r '.teamId // "null"' <<<"$ticket")" != "$team_id" ]; thenecho "$(jq -r .identifier <<<"$ticket") came back owned by $(jq -r '.teamId // "null"' <<<"$ticket"), not $team_id." >&2exit 1fijq -r --arg team "$team_id" '"Filed \(.identifier) for \($team): \(.title)"' <<<"$ticket"ticket_id=$(jq -r .id <<<"$ticket")List the team’s tickets
Section titled “List the team’s tickets”GET /ticketswithprojectIdandteamIdlists the tickets the team owns in the project. The recipe asks for the newest first and followsnextCursorto the last page, as List and filter does, then checks that the new ticket is there. Other tickets the team owns are listed too. The one create-ticket filed is there if you ran it first, because a key’s ticket goes to the default team.// 5. List the team's tickets in the project, newest first, following nextCursor to the end.console.log(`Tickets ${team.teamId} owns in ${projectId}, newest first:`);let cursor: string | null | undefined;let found = false;let listed = 0;do {// URLSearchParams escapes each value; `nextCursor` is opaque and may hold + / = &.const query = new URLSearchParams({ projectId, teamId: team.teamId, sort: 'created', direction: 'desc', limit: '50' });if (cursor) query.set('cursor', cursor);const page = await call<Schemas['TicketPage']>(`${api}/tickets?${query}`, { token });for (const t of page.items) console.log(` ${t.identifier.padEnd(10)} ${t.title}`);found ||= page.items.some((t) => t.id === ticket.id);listed += page.items.length;cursor = page.nextCursor;} while (cursor);console.log(`Listed ${listed} ticket(s).`);if (!found) throw new Error(`${ticket.identifier} is missing from the team's tickets.`);# 5. List the team's tickets in the project, newest first, following nextCursor to the end.print(f"Tickets {team['teamId']} owns in {project_id}, newest first:")cursor = Nonefound = Falselisted = 0while True:# urlencode escapes each value; `nextCursor` is opaque and may hold + / = &.query = {"projectId": project_id, "teamId": team["teamId"], "sort": "created", "direction": "desc", "limit": 50}if cursor:query["cursor"] = cursorpage = call(f"{api}/tickets?{urllib.parse.urlencode(query)}", token=token)for t in page["items"]:print(f" {t['identifier'].ljust(10)} {t['title']}")found = found or any(t["id"] == ticket["id"] for t in page["items"])listed += len(page["items"])cursor = page.get("nextCursor")if not cursor:breakprint(f"Listed {listed} ticket(s).")if not found:sys.exit(f"{ticket['identifier']} is missing from the team's tickets.")// 5. List the team's tickets in the project, newest first, following nextCursor to the end.System.out.println("Tickets " + team.teamId() + " owns in " + projectId + ", newest first:");String cursor = null;boolean found = false;int listed = 0;do {String query = "projectId=" + encode(projectId) + "&teamId=" + encode(team.teamId()) + "&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 = call("GET", api + "/tickets?" + query, token, null, new TypeReference<Envelope<TicketPage>>() {});for (Ticket t : page.items()) {System.out.println(String.format(" %-10s %s", t.identifier(), t.title()));found |= t.id().equals(ticket.id());}listed += page.items().size();cursor = page.nextCursor();} while (cursor != null);System.out.println("Listed " + listed + " ticket(s).");if (!found) throw new IllegalStateException(ticket.identifier() + " is missing from the team's tickets.");// 5. List the team's tickets in the project, newest first, following nextCursor to the end.Console.WriteLine($"Tickets {team.TeamId} owns in {projectId}, newest first:");string? cursor = null;var found = false;var listed = 0;do{var query = $"projectId={Uri.EscapeDataString(projectId)}&teamId={Uri.EscapeDataString(team.TeamId)}&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 http.GetAsync($"tickets?{query}"));foreach (var t in page.Items) Console.WriteLine($" {t.Identifier,-10} {t.Title}");found |= page.Items.Any(t => t.Id == ticket.Id);listed += page.Items.Count;cursor = page.NextCursor;} while (cursor is not null);Console.WriteLine($"Listed {listed} ticket(s).");if (!found) throw new InvalidOperationException($"{ticket.Identifier} is missing from the team's tickets.");// 5. List the team's tickets in the project, newest first, following nextCursor to the end.fmt.Printf("Tickets %s owns in %s, newest first:\n", team.TeamID, projectID)// url.Values escapes each value; `nextCursor` is opaque and may hold + / = &.query := url.Values{"projectId": {projectID}, "teamId": {team.TeamID}, "sort": {"created"}, "direction": {"desc"}, "limit": {"50"},}found := falselisted := 0for {page, err := call[ticketPage](http.MethodGet, api+"/tickets?"+query.Encode(), token, nil)check(err)for _, t := range page.Items {fmt.Printf(" %-10s %s\n", t.Identifier, t.Title)found = found || t.ID == created.ID}listed += len(page.Items)if page.NextCursor == nil || *page.NextCursor == "" {break}query.Set("cursor", *page.NextCursor)}fmt.Printf("Listed %d ticket(s).\n", listed)if !found {fail(created.Identifier + " is missing from the team's tickets.")}Terminal window # 5. List the team's tickets in the project, newest first, following nextCursor to the end.echo "Tickets $team_id owns in $project_id, newest first:"cursor=''found=falselisted=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 + / = &.page=$(curl -sS -G "$api/tickets" -H "Authorization: Bearer $token" \--data-urlencode "projectId=$project_id" --data-urlencode "teamId=$team_id" -d sort=created -d direction=desc -d limit=50 \${cursor:+--data-urlencode "cursor=$cursor"} | jq "$DATA")jq -r 'def pad($n): . + " " * ($n - length); .items[] | " \(.identifier | pad(10)) \(.title)"' <<<"$page"if [ "$(jq --arg id "$ticket_id" 'any(.items[]; .id == $id)' <<<"$page")" = true ]; then found=true; filisted=$((listed + $(jq '.items | length' <<<"$page")))cursor=$(jq -r '.nextCursor // empty' <<<"$page")if [ -z "$cursor" ]; then break; fidoneecho "Listed $listed ticket(s)."if [ "$found" != true ]; thenecho "$(jq -r .identifier <<<"$ticket") is missing from the team's tickets." >&2exit 1fiTake the ticket off the team
Section titled “Take the ticket off the team”PATCH /tickets/{ticketId}with"teamId": nullleaves the ticket with no team. Its identifier, column and cycle don’t change. Send another team’s ID instead to move the ticket to that team.The recipe prints the ticket’s
idlast, so you can find it again, or archive it when you’re done.// 6. Take the ticket off the team: `teamId: null`. Only the team changes; the ticket keeps its// identifier, column and cycle.const update: Schemas['TicketUpdate'] = { teamId: null };const cleared = await call<Schemas['Ticket']>(`${api}/tickets/${encodeURIComponent(ticket.id)}`, { method: 'PATCH', body: update, token });if (cleared.teamId != null) throw new Error(`${cleared.identifier} is still owned by ${cleared.teamId}.`);console.log(`Took ${cleared.identifier} off ${team.teamId}: no team owns it now.`);// The id, so you can find the ticket again (or archive it) afterwards.console.log(cleared.id);# 6. Take the ticket off the team: `teamId: null`. Only the team changes; the ticket keeps its# identifier, column and cycle.cleared = call(f"{api}/tickets/{urllib.parse.quote(ticket['id'], safe='')}", "PATCH", {"teamId": None}, token)if cleared.get("teamId") is not None:sys.exit(f"{cleared['identifier']} is still owned by {cleared['teamId']}.")print(f"Took {cleared['identifier']} off {team['teamId']}: no team owns it now.")# The id, so you can find the ticket again (or archive it) afterwards.print(cleared["id"])// 6. Take the ticket off the team: `teamId: null`. Only the team changes; the ticket keeps its// identifier, column and cycle. (Map.of can't hold a null, so the body is a singletonMap.)Ticket cleared = call("PATCH", api + "/tickets/" + encode(ticket.id()), token, Collections.singletonMap("teamId", null), new TypeReference<Envelope<Ticket>>() {});if (cleared.teamId() != null) throw new IllegalStateException(cleared.identifier() + " is still owned by " + cleared.teamId() + ".");System.out.println("Took " + cleared.identifier() + " off " + team.teamId() + ": no team owns it now.");// The id, so you can find the ticket again (or archive it) afterwards.System.out.println(cleared.id());// 6. Take the ticket off the team: `teamId: null`. Only the team changes; the ticket keeps its// identifier, column and cycle.var cleared = await ReadAsync<Ticket>(await http.PatchAsJsonAsync($"tickets/{Uri.EscapeDataString(ticket.Id)}", new { teamId = (string?)null }));if (cleared.TeamId is not null) throw new InvalidOperationException($"{cleared.Identifier} is still owned by {cleared.TeamId}.");Console.WriteLine($"Took {cleared.Identifier} off {team.TeamId}: no team owns it now.");// The id, so you can find the ticket again (or archive it) afterwards.Console.WriteLine(cleared.Id);// 6. Take the ticket off the team: `teamId: null`. Only the team changes; the ticket keeps its// identifier, column and cycle.cleared, err := call[ticket](http.MethodPatch, api+"/tickets/"+url.PathEscape(created.ID), token, map[string]any{"teamId": nil})check(err)if cleared.TeamID != nil {fail(fmt.Sprintf("%s is still owned by %s.", cleared.Identifier, *cleared.TeamID))}fmt.Printf("Took %s off %s: no team owns it now.\n", cleared.Identifier, team.TeamID)// The id, so you can find the ticket again (or archive it) afterwards.fmt.Println(cleared.ID)Terminal window # 6. Take the ticket off the team: `teamId: null`. Only the team changes; the ticket keeps its# identifier, column and cycle.cleared=$(jq -n '{teamId: null}' |curl -sS -X PATCH "$api/tickets/$(jq -rn --arg id "$ticket_id" '$id | @uri')" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' --data @- |jq "$DATA")if [ "$(jq '.teamId != null' <<<"$cleared")" = true ]; thenecho "$(jq -r .identifier <<<"$cleared") is still owned by $(jq -r .teamId <<<"$cleared")." >&2exit 1fijq -r --arg team "$team_id" '"Took \(.identifier) off \($team): no team owns it now."' <<<"$cleared"# The id, so you can find the ticket again (or archive it) afterwards.jq -r .id <<<"$cleared"
Run it in your browser
Section titled “Run it in your browser”Run the TypeScript recipe from this page. It files a real ticket in your organization, so use a test organization. The ticket’s ID then fills in the update and comment recipes for you. 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.
team-tickets.tsThe same code CI runs, in your browserteam-tickets.ts…Attach a team to a project
Section titled “Attach a team to a project”A team has to be attached to a project before the project’s tickets can be owned by it. Attaching is a project
admin’s job, so these routes need a signed-in member’s token from someone who can manage the project, and an API key
gets 403 forbidden:
PUT /projects/{projectId}/teams/{teamId}attaches the team. Send{ "isDefault": true }to make it the project’s default team at the same time. The previous default stops being one. Calling it again for an attached team changes only what you send.DELETE /projects/{projectId}/teams/{teamId}detaches it. The tickets it already owns keep it, but it’s no longer offered for the project’s tickets or given to new ones. A detached default leaves the project with no default team.
Most integrations never need these: people attach teams in the project’s settings in the app.
An epic’s lead and contributing teams
Section titled “An epic’s lead and contributing teams”An epic names its teams in two ways. Its lead team (leadTeamId) is the one team answerable for it, and you set it
with POST /epics or PATCH /epics/{epicId}. Unlike a ticket’s team, it doesn’t have to work in the epic’s project.
Its contributing teams (contributingTeamIds) are the teams that own its tickets, so moving a ticket to another team
changes them too. See Group work into epics.
When a team is archived
Section titled “When a team is archived”An organization admin can archive a team in the app. When they do, By Dawn clears it from every ticket it owned (their
teamId becomes null), detaches it from every project and clears it as the lead team of every epic it led. So a
teamId you stored can stop being valid: if filing with it returns invalid_team, read the project’s teams again
rather than retrying.
Do and don’t
Section titled “Do and don’t”- Do read
GET /projects/{projectId}/teamswhen your integration starts, or when it getsinvalid_team, rather than on every request. - Do store team IDs, not names, because people rename teams. Look the name up when you show it.
- Don’t use the team to decide a ticket’s identifier or column. They come from the project, and they don’t change when the team does.
What isn’t in the API yet
Section titled “What isn’t in the API yet”- Giving a team access to a restricted project. Project access is set in the app, see Project members and access.
Where to go next
Section titled “Where to go next”- Teams: what a team is in the app, and who can attach one to a project.
- List and filter: the other filters
GET /ticketstakes. - What a key can’t do: the routes that need a person’s token.
- The API reference lists the team routes under Teams.