# Organize work by team

> Find the teams that work in a project and their names, file and move tickets by team, and list each team's work.

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](#recipe-file-list-and-clear-a-teams-ticket) does all
three in every language.

## 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](https://docs.bydawn.com/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

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`](https://docs.bydawn.com/reference/#api/tag/teams/listProjectTeams) lists them, oldest first:

```json
{
  "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](https://docs.bydawn.com/developers/authentication/#scopes)).

## 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`](https://docs.bydawn.com/reference/#api/tag/teams/listTeams). 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](https://docs.bydawn.com/developers/authentication/#what-a-key-cant-do)). `{orgId}` is the `org_id` claim of the token
(see [Your organization ID](https://docs.bydawn.com/developers/authentication/#your-organization-id)).

```text
GET https://accounts.dev.sawrun.com/api/v1/orgs/{orgId}/teams
```

It 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:

```json
{
  "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](https://docs.bydawn.com/reference/) lists every field.

## File a ticket for a team

Send `teamId` with [`POST /tickets`](https://docs.bydawn.com/reference/#api/tag/tickets/createTicket):

```json
{ "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:

```json
{ "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](https://docs.bydawn.com/developers/responses-and-errors/)).
An archived team is refused the same way.

## Move a ticket to another team

Send the new `teamId` with [`PATCH /tickets/{ticketId}`](https://docs.bydawn.com/reference/#api/tag/tickets/updateTicket), or `null` to take the ticket off its team:

```json
{ "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

Filter [`GET /tickets`](https://docs.bydawn.com/reference/#api/tag/tickets/listTickets) by `teamId`, in one project at a time:

```text
GET /tickets?projectId=project-3a86…&teamId=team-8c3a…
```

- **Repeat `teamId`** to list the tickets of any of several teams, up to 50.
- **`teamId=none`** lists 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](https://docs.bydawn.com/developers/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`](https://docs.bydawn.com/reference/#api/tag/teams/listTeamTickets). 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

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](https://docs.bydawn.com/developers/authentication/#scopes)). 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

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](https://docs.bydawn.com/developers/recipes/create-ticket/) does.

### Find the project's teams

`GET /projects/{projectId}/teams` lists 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](https://docs.bydawn.com/teams/)).

### File a ticket for the team

`POST /tickets` with a `teamId` files the ticket for that team, and the ticket comes back with the same `teamId`. The
recipe checks that it did. A team that doesn't work in the project would be refused with `invalid_team`.

### List the team's tickets

`GET /tickets` with `projectId` and `teamId` lists the tickets the team owns in the project. The recipe asks for the
newest first and follows `nextCursor` to the last page, as [List and filter](https://docs.bydawn.com/developers/recipes/list-tickets/)
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.

### Take the ticket off the team

`PATCH /tickets/{ticketId}` with `"teamId": null` leaves 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 `id` last, so you can find it again, or archive it when you're done.

The whole recipe:

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

```ts
// Organize work by team: file a ticket owned by one of the project's teams, list that team's
// tickets, then take the ticket off the team again.
// 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 team-tickets.ts            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… node team-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;
  constructor(status: number, error: ErrorSlug, message?: string) {
    super(`${status} ${error}${message ? ` — ${message}` : ''}`);
    this.status = status;
    this.error = error;
  }
}

// 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);
}

// 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. 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)' : ''}`);

  // 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}`);

  // 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.`);

  // 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);
}
```

Run it:

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

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

```python
# Organize work by team: file a ticket owned by one of the project's teams, list that team's
# tickets, then take the ticket off the team again.
# Standard library only (urllib and json), Python 3.9 or later.
#
#   SAW_API_KEY=saw_live_… python3 team_tickets.py            (the first project)
#   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… python3 team_tickets.py
import base64
import json
import os
import sys
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):
        super().__init__(f"{status} {error}" + (f" — {message}" if message else ""))
        self.status = status
        self.error = error

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)
        raise ApiError(failure.code, envelope["error"], envelope.get("message")) 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.")

# 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 ""))

# 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']}")

# 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 = None
found = False
listed = 0
while 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"] = cursor
    page = 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:
        break
print(f"Listed {listed} ticket(s).")
if not found:
    sys.exit(f"{ticket['identifier']} is missing from the team's tickets.")

# 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"])
```

Run it:

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

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

```java
// Organize work by team: file a ticket owned by one of the project's teams, list that team's
// tickets, then take the ticket off the team again.
// 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 TeamTickets.java            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… jbang TeamTickets.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.Collections;
import java.util.List;
import java.util.Map;

public class TeamTickets {
  /** 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 ProjectTeam(String teamId, boolean isDefault) {}
  record Ticket(String id, String identifier, String title, String teamId) {}
  record TicketPage(List<Ticket> items, String nextCursor) {}

  /** A failed call. Branch on error(), the stable slug, never on the message. */
  static class ApiException extends RuntimeException {
    final int status;
    final String error;

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

  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. 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)" : ""));

    // 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());

    // 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.");

    // 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());
  }

  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();
    throw new ApiException(response.statusCode(), envelope.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 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 TeamTickets.java
```

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

```csharp
// Organize work by team: file a ticket owned by one of the project's teams, list that team's
// tickets, then take the ticket off the team again.
// Plain HttpClient and System.Net.Http.Json, both part of .NET: no NuGet packages.
//
//   SAW_API_KEY=saw_live_… dotnet run team-tickets.cs            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… dotnet run team-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. 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)" : "")}");

// 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}");

// 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.");

// 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);

// Helpers: the functions and types the steps above use.
// 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 ProjectTeam(string TeamId, bool IsDefault);
record Ticket(string Id, string Identifier, string Title, string? TeamId);
record TicketPage(List<Ticket> Items, string? NextCursor);

/// <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 team-tickets.cs
```

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

```go
// Organize work by team: file a ticket owned by one of the project's teams, list that team's
// tickets, then take the ticket off the team again.
// Standard library only (net/http and encoding/json).
//
//	SAW_API_KEY=saw_live_… go run team_tickets.go            (the first project)
//	SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… go run team_tickets.go
package main

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

// 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
}

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"`
}

// projectTeam is a team attached to a project.
type projectTeam struct {
	TeamID    string `json:"teamId"`
	IsDefault bool   `json:"isDefault"`
}

type ticket struct {
	ID         string  `json:"id"`
	Identifier string  `json:"identifier"`
	Title      string  `json:"title"`
	TeamID     *string `json:"teamId"`
}

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

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. 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 = t
			break
		}
	}
	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)
		}
	}

	// 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)

	// 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 := false
	listed := 0
	for {
		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.")
	}

	// 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)
}

// deref reads an optional string, or "null" when it's absent.
func deref(value *string) string {
	if value == nil {
		return "null"
	}
	return *value
}

// 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 {
		return data, &apiError{Status: res.StatusCode, Slug: out.Error, Message: out.Message}
	}
	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 team_tickets.go
```

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

```bash
#!/usr/bin/env bash
# Organize work by team: file a ticket owned by one of the project's teams, list that team's
# tickets, then take the ticket off the team again.
# Needs curl and jq: jq builds the request bodies and reads the responses.
#
#   SAW_API_KEY=saw_live_… bash team-tickets.sh            (the first project)
#   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… bash team-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. 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" ]; then
  echo "No team works in $project_id yet. Attach one in the project's settings in the app, then run this again."
  exit 0
fi
echo "$project_id has $(jq length <<<"$teams") team(s):"
jq -r '.[] | "  \(.teamId)\(if .isDefault then "  (default)" else "" end)"' <<<"$teams"

# 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" ]; then
  echo "$(jq -r .identifier <<<"$ticket") came back owned by $(jq -r '.teamId // "null"' <<<"$ticket"), not $team_id." >&2
  exit 1
fi
jq -r --arg team "$team_id" '"Filed \(.identifier) for \($team): \(.title)"' <<<"$ticket"
ticket_id=$(jq -r .id <<<"$ticket")

# 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=false
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 + / = &.
  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; fi
  listed=$((listed + $(jq '.items | length' <<<"$page")))
  cursor=$(jq -r '.nextCursor // empty' <<<"$page")
  if [ -z "$cursor" ]; then break; fi
done
echo "Listed $listed ticket(s)."
if [ "$found" != true ]; then
  echo "$(jq -r .identifier <<<"$ticket") is missing from the team's tickets." >&2
  exit 1
fi

# 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 ]; then
  echo "$(jq -r .identifier <<<"$cleared") is still owned by $(jq -r .teamId <<<"$cleared")." >&2
  exit 1
fi
jq -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:

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

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

```text
project-1 has 2 team(s):
  team-platform
  team-billing  (default)
Filed WEB-6 for team-billing: Filed for a team by the team-tickets recipe
Tickets team-billing owns in project-1, newest first:
  WEB-6      Filed for a team by the team-tickets recipe
  WEB-5      Updated by the update-ticket recipe
  WEB-1      Export the board as CSV
Listed 3 ticket(s).
Took WEB-6 off team-billing: no team owns it now.
ticket-0006
```

## 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](https://docs.bydawn.com/developers/recipes/update-ticket/) and
[comment](https://docs.bydawn.com/developers/recipes/comments/) recipes for you. 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/organize-work-by-team/).

## 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}`](https://docs.bydawn.com/reference/#api/tag/teams/upsertProjectTeam)** 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}`](https://docs.bydawn.com/reference/#api/tag/teams/deleteProjectTeam)** 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

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](https://docs.bydawn.com/developers/epics/).

## 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

- **Do** read `GET /projects/{projectId}/teams` when your integration starts, or when it gets `invalid_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

- **Giving a team access to a restricted project.** Project access is set in the app, see
  [Project members and access](https://docs.bydawn.com/project-access/).

## Where to go next

- [Teams](https://docs.bydawn.com/teams/): what a team is in the app, and who can attach one to a project.
- [List and filter](https://docs.bydawn.com/developers/recipes/list-tickets/): the other filters `GET /tickets` takes.
- [What a key can't do](https://docs.bydawn.com/developers/authentication/#what-a-key-cant-do): the routes that need a person's token.
- The [API reference](https://docs.bydawn.com/reference/) lists the team routes under **Teams**.

Source: https://docs.bydawn.com/developers/organize-work-by-team/
