Responses and errors
Every answer from the API, whether it worked or not, has the same shape. So your code needs just one check to know whether a call worked, and one field to know what went wrong. This page shows that shape, the errors you’ll meet most, and which ones are worth retrying.
The envelope
Section titled “The envelope”We call the shape the envelope. When a call works, ok is true and what you asked for is
in data:
{ "ok": true, "data": { "id": "ticket-…", "identifier": "SAW-42", "title": "Export the board as CSV" } }When it fails, ok is false and error holds a slug: a short, fixed code such as
ticket_not_found. Sometimes a message comes with it, with extra detail for your logs:
{ "ok": false, "error": "ticket_not_found" }Write your error handling against the slug. Slugs are part of the API’s contract: they don’t
change without notice, and any change is recorded in the API changelog. The
message wording and the HTTP status text can change at any time, so don’t parse them.
The errors you’ll meet most
Section titled “The errors you’ll meet most”| Status | error |
What happened | What to do |
|---|---|---|---|
| 401 | invalid_api_key |
The key exchange was refused: the key is wrong, revoked or expired. | Check the key, or create a new one. |
| 401 | unauthorized |
The access token is missing or has expired. | Exchange the key for a new token, once. |
| 403 | forbidden |
The key isn’t allowed to do this, in this organization. | Check the key’s scopes. |
| 404 | not_found, ticket_not_found, project_not_found |
There’s no such thing in this organization. | Don’t retry. |
| 422 | validation_error, invalid_status, invalid_project, … |
Something in the request isn’t valid. message says which field. |
Fix the request. Don’t retry. |
| 429 | rate_limited |
Too many requests in the last minute. | Wait Retry-After seconds, then retry. See rate limits. |
The API reference lists the errors each operation can return, and the full set of
slugs is in its ApiErrorSlug and AccountsApiErrorSlug schemas. If you meet a slug you don’t
recognise, handle it the way you’d handle its HTTP status.
When to retry
Section titled “When to retry”Retry only when waiting can change the answer:
401 unauthorized: exchange the key for a fresh token once, and repeat the request. If that fails too, stop, because the key itself has been revoked or has expired.429 rate_limited: wait theRetry-Afterseconds, then send the same request again.
Everything else will fail the same way next time. Retrying a 404 or 422 just repeats the
mistake, and retrying a 401 or 403 in a loop won’t fix a wrong key or a missing scope.
Recipe: handle an error by its slug
Section titled “Recipe: handle an error by its slug”Like every recipe, this one checks each response and turns a failure into an error that carries the status, the slug and the message. Then it sets off three real failures on purpose and handles each one by its slug. It writes nothing: the one ticket it tries to create is refused.
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"Something that doesn’t exist: 404
Section titled “Something that doesn’t exist: 404”The recipe asks for a project that isn’t there. Depending on the operation, the slug is the specific
project_not_foundor the generalnot_found, so it handles both. A404won’t change if you ask again, so there’s nothing to retry.// 2. A resource that does not exist: 404.const missing = await failure(call(`${api}/projects/project-00000000-0000-0000-0000-000000000000`, { token }));switch (missing.error) {case 'project_not_found':case 'not_found':console.log(`${missing.status} ${missing.error}: no such project in this organization.`);break;default:throw missing;}# 2. A resource that does not exist: 404.missing = failure(lambda: call(f"{api}/projects/project-00000000-0000-0000-0000-000000000000", token=token))if missing.error in ("project_not_found", "not_found"):print(f"{missing.status} {missing.error}: no such project in this organization.")else:raise missing// 2. A resource that does not exist: 404.ApiException missing = failure(() -> call("GET", api + "/projects/project-00000000-0000-0000-0000-000000000000", token, null,new TypeReference<Envelope<Project>>() {}));switch (missing.error) {case "project_not_found", "not_found" -> System.out.println(missing.status + " " + missing.error + ": no such project in this organization.");default -> throw missing;}// 2. A resource that does not exist: 404.try{await ReadAsync<Project>(await http.GetAsync("projects/project-00000000-0000-0000-0000-000000000000"));throw new InvalidOperationException("Expected the call to fail.");}catch (ApiException e) when (e.Error is "project_not_found" or "not_found"){Console.WriteLine($"{(int)e.Status} {e.Error}: no such project in this organization.");}// 2. A resource that does not exist: 404._, err = call[project](http.MethodGet, api+"/projects/project-00000000-0000-0000-0000-000000000000", token, nil)missing := failure(err)switch missing.Slug {case "project_not_found", "not_found":fmt.Printf("%d %s: no such project in this organization.\n", missing.Status, missing.Slug)default:check(missing)}Terminal window # 2. A resource that does not exist: 404.missing=$(failure "$api/projects/project-00000000-0000-0000-0000-000000000000" -H "Authorization: Bearer $token")read -r status slug _ <<<"$missing"case $slug inproject_not_found | not_found) echo "$status $slug: no such project in this organization." ;;*)echo "$missing" >&2exit 1;;esacA request that isn’t valid: 422
Section titled “A request that isn’t valid: 422”Next it tries to create a ticket with an empty title. The slug,
validation_error, is what the code checks. Themessagesays which field is wrong, which is for the person reading your logs, so the recipe prints it rather than parsing it.// 3. An invalid request: 422. The message says what is wrong; it is for people, so log it, don't parse it.const [project] = await call<Schemas['Project'][]>(`${api}/projects`, { token });if (!project) throw new Error('This organization has no projects yet.');const invalid = await failure(call(`${api}/tickets`, { method: 'POST', body: { projectId: project.id, title: '' }, token }));if (invalid.error !== 'validation_error') throw invalid;console.log(invalid.message);# 3. An invalid request: 422. The message says what is wrong; it is for people, so log it, don't parse it.projects = call(f"{api}/projects", token=token) or sys.exit("This organization has no projects yet.")invalid = failure(lambda: call(f"{api}/tickets", "POST", {"projectId": projects[0]["id"], "title": ""}, token))if invalid.error != "validation_error":raise invalidprint(invalid)// 3. An invalid request: 422. The message says what is wrong; it is for people, so log it, don't parse it.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.");ApiException invalid = failure(() -> call("POST", api + "/tickets", token, Map.of("projectId", projects.get(0).id(), "title", ""),new TypeReference<Envelope<Ticket>>() {}));if (!invalid.error.equals("validation_error")) throw invalid;System.out.println(invalid.getMessage());// 3. An invalid request: 422. The message says what is wrong; it is for people, so log it, don't parse it.var project = (await ReadAsync<List<Project>>(await http.GetAsync("projects"))).FirstOrDefault()?? throw new InvalidOperationException("This organization has no projects yet.");try{await ReadAsync<Ticket>(await http.PostAsJsonAsync("tickets", new { projectId = project.Id, title = "" }));throw new InvalidOperationException("Expected the call to fail.");}catch (ApiException e) when (e.Error is "validation_error"){Console.WriteLine(e.Message);}// 3. An invalid request: 422. The message says what is wrong; it is for people, so log it, don't parse it.projects, err := call[[]project](http.MethodGet, api+"/projects", token, nil)check(err)if len(projects) == 0 {fail("This organization has no projects yet.")}_, err = call[ticket](http.MethodPost, api+"/tickets", token, map[string]string{"projectId": projects[0].ID, "title": ""})invalid := failure(err)if invalid.Slug != "validation_error" {check(invalid)}fmt.Println(invalid)Terminal window # 3. An invalid request: 422. The message says what is wrong; it is for people, so log it, don't parse it.project_id=$(curl -sS "$api/projects" -H "Authorization: Bearer $token" | jq -r "$DATA | .[0].id // empty")if [ -z "$project_id" ]; thenecho 'This organization has no projects yet.' >&2exit 1fiinvalid=$(jq -n --arg projectId "$project_id" '{projectId: $projectId, title: ""}' |failure -X POST "$api/tickets" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' --data @-)read -r status slug _ <<<"$invalid"if [ "$slug" != validation_error ]; thenecho "$invalid" >&2exit 1fiecho "$invalid"A key that doesn’t work: 401
Section titled “A key that doesn’t work: 401”Last, it tries to exchange a key that doesn’t exist. The accounts host refuses it with
invalid_api_key, which is your cue to check the key or create a new one, not to try again.// 4. An API key that is wrong or revoked: the token exchange is refused.const wrongKey: Schemas['ApiKeyExchange'] = { apiKey: 'saw_live_not_a_real_key' };const refused = await failure(call(`${ACCOUNTS_URL}/auth/token/api-key`, { method: 'POST', body: wrongKey }));if (refused.error !== 'invalid_api_key') throw refused;console.log(`${refused.status} ${refused.error}: check the key, or create a new one in Settings → Security.`);# 4. An API key that is wrong or revoked: the token exchange is refused.refused = failure(lambda: call(f"{ACCOUNTS_URL}/auth/token/api-key", "POST", {"apiKey": "saw_live_not_a_real_key"}))if refused.error != "invalid_api_key":raise refusedprint(f"{refused.status} {refused.error}: check the key, or create a new one in Settings → Security.")// 4. An API key that is wrong or revoked: the token exchange is refused.ApiException refused = failure(() -> call("POST", accountsUrl + "/auth/token/api-key", null, Map.of("apiKey", "saw_live_not_a_real_key"),new TypeReference<Envelope<AccessToken>>() {}));if (!refused.error.equals("invalid_api_key")) throw refused;System.out.println(refused.status + " " + refused.error + ": check the key, or create a new one in Settings → Security.");// 4. An API key that is wrong or revoked: the token exchange is refused.try{await ReadAsync<AccessTokenData>(await accounts.PostAsJsonAsync($"{accountsUrl}/auth/token/api-key", new { apiKey = "saw_live_not_a_real_key" }));throw new InvalidOperationException("Expected the call to fail.");}catch (ApiException e) when (e.Error is "invalid_api_key"){Console.WriteLine($"{(int)e.Status} {e.Error}: check the key, or create a new one in Settings → Security.");}// 4. An API key that is wrong or revoked: the token exchange is refused._, err = call[accessToken](http.MethodPost, accountsURL+"/auth/token/api-key", "", map[string]string{"apiKey": "saw_live_not_a_real_key"})refused := failure(err)if refused.Slug != "invalid_api_key" {check(refused)}fmt.Printf("%d %s: check the key, or create a new one in Settings → Security.\n", refused.Status, refused.Slug)Terminal window # 4. An API key that is wrong or revoked: the token exchange is refused.refused=$(jq -n '{apiKey: "saw_live_not_a_real_key"}' |failure "$ACCOUNTS_URL/auth/token/api-key" -H 'Content-Type: application/json' --data @-)read -r status slug _ <<<"$refused"if [ "$slug" != invalid_api_key ]; thenecho "$refused" >&2exit 1fiecho "$status $slug: check the key, or create a new one in Settings → Security."
Run it in your browser
Section titled “Run it in your browser”Run the TypeScript recipe from this page. 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.
errors.tsThe same code CI runs, in your browsererrors.ts…