Skip to content
Docs
About By Dawn Contact supportOpen By Dawn
  1. Work with tickets
  2. Organize work by team

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.

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.

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

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

{
"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.

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.

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.

Filter GET /tickets by teamId, in one project at a time:

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

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.

  1. 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)}`;
  2. 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.');
  3. 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).

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

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

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

Connect 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.

▶ Run team-tickets.tsThe same code CI runs, in your browser

This recipe files a ticket for one of the project's teams, then takes it off the team in your organization.

Connect your organization above to run it.

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

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