# Create a ticket

> Turn an alert, a form or an email into a ticket on the right board.

Use this to file work in By Dawn from another tool: an alert that fires, a form someone fills in, an
email to a support address. By the end you'll have a ticket in a project's backlog, and its ID to
update or comment on it later.

**Before you start:** you need 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

Tickets always belong to a project, so the recipe needs one. In your own integration you'll
usually know it already, for example one project per team or per alert source.
`GET /projects` lists them if you need to look one up.

### Create the ticket

`POST /tickets` needs only a `projectId` and a `title`. Leave out the column and the ticket lands
in the project's backlog, where people triage new work.

The answer is the new ticket. The recipe prints its identifier, such as `WEB-5`, for people,
then its `id` for code: that's what you pass to [update](https://docs.bydawn.com/developers/recipes/update-ticket/) or
[comment on](https://docs.bydawn.com/developers/recipes/comments/) it.

The whole recipe:

**TypeScript** (`create-ticket.ts`):

```ts
// Create a ticket in a project. It lands in the project's backlog column.
// 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 create-ticket.ts            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… node create-ticket.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. Create the ticket. Only projectId and title are required, and it lands in the backlog column.
const create: Schemas['TicketCreate'] = {
  projectId,
  title: 'Created by the create-ticket recipe',
  description: 'This ticket was created through the SAW API.',
  priority: 'LOW',
};
const ticket = await call<Schemas['Ticket']>(`${api}/tickets`, { method: 'POST', body: create, token });

console.log(`Created ${ticket.identifier}: ${ticket.title}`);
// The id is what every other ticket operation takes, e.g. `node update-ticket.ts <id>`.
console.log(ticket.id);
```

Run it:

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

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

```python
# Create a ticket in a project. It lands in the project's backlog column.
# Standard library only (urllib and json), Python 3.9 or later.
#
#   SAW_API_KEY=saw_live_… python3 create_ticket.py            (the first project)
#   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… python3 create_ticket.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)}"

# 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. Create the ticket. Only projectId and title are required, and it lands in the backlog column.
ticket = call(
    f"{api}/tickets",
    "POST",
    {
        "projectId": project_id,
        "title": "Created by the create-ticket recipe",
        "description": "This ticket was created through the SAW API.",
        "priority": "LOW",
    },
    token,
)

print(f"Created {ticket['identifier']}: {ticket['title']}")
# The id is what every other ticket operation takes, e.g. `python3 update_ticket.py <id>`.
print(ticket["id"])
```

Run it:

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

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

```java
// Create a ticket in a project. It lands in the project's backlog column.
// 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 CreateTicket.java            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… jbang CreateTicket.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.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Base64;
import java.util.List;
import java.util.Map;

public class CreateTicket {
  /** 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 {
    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. Create the ticket. Only projectId and title are required, and it lands in the backlog column.
    Ticket ticket = call("POST", api + "/tickets", token, Map.of(
        "projectId", projectId,
        "title", "Created by the create-ticket recipe",
        "description", "This ticket was created through the SAW API.",
        "priority", "LOW"), new TypeReference<Envelope<Ticket>>() {});

    System.out.println("Created " + ticket.identifier() + ": " + ticket.title());
    // The id is what every other ticket operation takes, e.g. `jbang UpdateTicket.java <id>`.
    System.out.println(ticket.id());
  }

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

**C#** (`create-ticket.cs`):

```csharp
// Create a ticket in a project. It lands in the project's backlog column.
// Plain HttpClient and System.Net.Http.Json, both part of .NET: no NuGet packages.
//
//   SAW_API_KEY=saw_live_… dotnet run create-ticket.cs            (the first project)
//   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… dotnet run create-ticket.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. Create the ticket. Only projectId and title are required, and it lands in the backlog column.
var ticket = await ReadAsync<Ticket>(await http.PostAsJsonAsync("tickets", new
{
    projectId,
    title = "Created by the create-ticket recipe",
    description = "This ticket was created through the SAW API.",
    priority = "LOW",
}));

Console.WriteLine($"Created {ticket.Identifier}: {ticket.Title}");
// The id is what every other ticket operation takes, e.g. `dotnet run update-ticket.cs -- <id>`.
Console.WriteLine(ticket.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 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 create-ticket.cs
```

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

```go
// Create a ticket in a project. It lands in the project's backlog column.
// Standard library only (net/http and encoding/json).
//
//	SAW_API_KEY=saw_live_… go run create_ticket.go            (the first project)
//	SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… go run create_ticket.go
package main

import (
	"bytes"
	"encoding/base64"
	"encoding/json"
	"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. 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. Create the ticket. Only projectId and title are required, and it lands in the backlog column.
	created, err := call[ticket](http.MethodPost, api+"/tickets", token, map[string]string{
		"projectId":   projectID,
		"title":       "Created by the create-ticket recipe",
		"description": "This ticket was created through the SAW API.",
		"priority":    "LOW",
	})
	check(err)

	fmt.Printf("Created %s: %s\n", created.Identifier, created.Title)
	// The id is what every other ticket operation takes, e.g. `go run update_ticket.go <id>`.
	fmt.Println(created.ID)
}

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

**cURL** (`create-ticket.sh`):

```bash
#!/usr/bin/env bash
# Create a ticket in a project. It lands in the project's backlog column.
# Needs curl and jq: jq builds the request bodies and reads the responses.
#
#   SAW_API_KEY=saw_live_… bash create-ticket.sh            (the first project)
#   SAW_API_KEY=saw_live_… SAW_PROJECT_ID=project-… bash create-ticket.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. Create the ticket. Only projectId and title are required, and it lands in the backlog column.
ticket=$(jq -n --arg projectId "$project_id" '{
    projectId: $projectId,
    title: "Created by the create-ticket recipe",
    description: "This ticket was created through the SAW API.",
    priority: "LOW"
  }' |
  curl -sS -X POST "$api/tickets" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' --data @- |
  jq "$DATA")

jq -r '"Created \(.identifier): \(.title)"' <<<"$ticket"
# The id is what every other ticket operation takes, e.g. `bash update-ticket.sh <id>`.
jq -r .id <<<"$ticket"
```

Run it:

```sh
SAW_API_KEY=saw_live_… bash create-ticket.sh
```

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

```text
Created WEB-5: Created by the create-ticket recipe
ticket-0005
```

## Run it in your browser

Run the TypeScript recipe from this page. It creates a real ticket in your organization, so use a
test organization. The new 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.

> A runner for this recipe, with your own credential, is on the [web version of this page](https://docs.bydawn.com/developers/recipes/create-ticket/).

## Other fields you can set

Anything you can set now, you can also change [later](https://docs.bydawn.com/developers/recipes/update-ticket/):

- **What it is:** `description`, and `priority` (`NO_PRIORITY`, `URGENT`, `HIGH`, `MEDIUM` or
  `LOW`).
- **Where it sits:** `statusId`, a column from `GET /statuses?projectId=…`, and `labelIds`.
- **Who and when:** `assigneeId` and `dueDate`.
- **Planning:** `cycleId`, `epicId`, `milestoneId` and `estimate`. `GET /epics?projectId=…` lists the project's
  epics (see [Group work into epics](https://docs.bydawn.com/developers/epics/)).
- **Structure:** `parentTicketId`, which makes the ticket a sub-ticket.

An ID from another project, or a value the project doesn't accept, gets `422` with a slug such as
`invalid_status` or `validation_error`, and `message` says which field. See
[Responses and errors](https://docs.bydawn.com/developers/responses-and-errors/).

Source: https://docs.bydawn.com/developers/recipes/create-ticket/
