# Responses and errors

> The one response shape every By Dawn API call uses, the error slugs to branch on, and when to retry.

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

We call the shape the **envelope**. When a call works, `ok` is `true` and what you asked for is
in `data`:

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

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

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

The [API reference](https://docs.bydawn.com/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

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 the `Retry-After` seconds, 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

Like every [recipe](https://docs.bydawn.com/developers/try-it/#how-the-recipes-are-written), 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.

### Swap your key for an access token

Every recipe starts this way, as the [Quickstart](https://docs.bydawn.com/developers/quickstart/) explains.

### 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_found` or the general `not_found`, so it handles both. A `404` won't change
if you ask again, so there's nothing to retry.

### 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. The `message` says which field is wrong, which is for the person reading your logs,
so the recipe prints it rather than parsing it.

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

The whole recipe:

**TypeScript** (`errors.ts`):

```ts
// Handle errors by their slug. Every failure is `{ "ok": false, "error": "<slug>", "message": … }`;
// `call` below turns it into an ApiError whose `error` is that slug.
// 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 errors.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)}`;

// Helpers: the functions and types the steps above use.
// Run a call that should fail, and return the ApiError it failed with.
async function failure(pending: Promise<unknown>): Promise<ApiError> {
  try {
    await pending;
  } catch (err) {
    if (err instanceof ApiError) return err;
    throw err;
  }
  throw new Error('Expected the call to fail.');
}

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

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

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

Run it:

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

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

```python
# Handle errors by their slug. Every failure is `{ "ok": false, "error": "<slug>", "message": … }`;
# `call` below turns it into an ApiError whose `error` is that slug.
# Standard library only (urllib and json), Python 3.9 or later.
#
#   SAW_API_KEY=saw_live_… python3 errors.py
import base64
import json
import os
import sys
import urllib.error
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)}"

# Helpers: the functions and types the steps above use.
def failure(request):
    """Run a call that should fail, and return the ApiError it failed with."""
    try:
        request()
    except ApiError as err:
        return err
    raise RuntimeError("Expected the call to fail.")

# 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

# 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 invalid
print(invalid)

# 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 refused
print(f"{refused.status} {refused.error}: check the key, or create a new one in Settings → Security.")
```

Run it:

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

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

```java
// Handle errors by their slug. Every failure is `{ "ok": false, "error": "<slug>", "message": … }`;
// `call` below turns it into an ApiException whose error is that slug.
// 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 Errors.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.io.FileDescriptor;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.net.URI;
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.List;
import java.util.Map;

public class Errors {
  /** 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 Ticket(String id, String identifier, String title, String priority, String description) {}

  /** 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 {
    // Print → and — as UTF-8, whatever the terminal's locale.
    System.setOut(new PrintStream(new FileOutputStream(FileDescriptor.out), true, StandardCharsets.UTF_8));
    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. 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;
    }

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

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

  interface Call {
    void run() throws Exception;
  }

  /** Runs a call that should fail, and returns the ApiException it failed with. */
  static ApiException failure(Call call) throws Exception {
    try {
      call.run();
    } catch (ApiException e) {
      return e;
    }
    throw new IllegalStateException("Expected the call to fail.");
  }

  /** 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 Errors.java
```

**C#** (`errors.cs`):

```csharp
// Handle errors by their slug. Every failure is `{ "ok": false, "error": "<slug>", "message": … }`.
// ReadAsync below turns it into an ApiException whose `Error` is that slug.
// Plain HttpClient and System.Net.Http.Json, both part of .NET: no NuGet packages.
//
//   SAW_API_KEY=saw_live_… dotnet run errors.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. 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.");
}

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

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

// 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 Ticket(string Id, string Identifier, string Title, string Priority, string? Description);

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

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

```go
// Handle errors by their slug. Every failure is `{ "ok": false, "error": "<slug>", "message": … }`;
// `call` below turns it into an *apiError whose Slug is that slug.
// Standard library only (net/http and encoding/json).
//
//	SAW_API_KEY=saw_live_… go run errors.go
package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"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"`
}

type ticket struct {
	ID          string  `json:"id"`
	Identifier  string  `json:"identifier"`
	Title       string  `json:"title"`
	Description *string `json:"description"`
	Priority    string  `json:"priority"`
}

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

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

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

// failure returns the *apiError a call that should fail failed with.
func failure(err error) *apiError {
	var apiErr *apiError
	if errors.As(err, &apiErr) {
		return apiErr
	}
	if err == nil {
		fail("Expected the call to fail.")
	}
	check(err)
	return nil
}

// 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 errors.go
```

**cURL** (`errors.sh`):

```bash
#!/usr/bin/env bash
# Handle errors by their slug. Every failure is `{ "ok": false, "error": "<slug>", "message": … }`.
# Needs curl and jq: jq builds the request bodies and reads the responses.
#
#   SAW_API_KEY=saw_live_… bash errors.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"

# Helpers: the functions and types the steps above use.
# failure <curl arguments…>: send a request that should fail, and print
# `<status> <slug>[ — <message>]`. -w prints the HTTP status on a line after the body.
failure() {
  local response
  response=$(curl -sS -w '\n%{http_code}' "$@")
  sed '$d' <<<"$response" | jq -r --arg status "$(tail -n 1 <<<"$response")" '
    if .ok then "Expected the call to fail." | halt_error
    else "\($status) \(.error)" + (if .message then " — \(.message)" else "" end) end'
}

# 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 in
  project_not_found | not_found) echo "$status $slug: no such project in this organization." ;;
  *)
    echo "$missing" >&2
    exit 1
    ;;
esac

# 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" ]; then
  echo 'This organization has no projects yet.' >&2
  exit 1
fi
invalid=$(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 ]; then
  echo "$invalid" >&2
  exit 1
fi
echo "$invalid"

# 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 ]; then
  echo "$refused" >&2
  exit 1
fi
echo "$status $slug: check the key, or create a new one in Settings → Security."
```

Run it:

```sh
SAW_API_KEY=saw_live_… bash errors.sh
```

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

```text
404 project_not_found: no such project in this organization.
422 validation_error — title is required
401 invalid_api_key: check the key, or create a new one in Settings → Security.
```

## Run it in your browser

Run the TypeScript recipe from this page. 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/responses-and-errors/).

Source: https://docs.bydawn.com/developers/responses-and-errors/
