# Comment on a ticket

> Post updates on a ticket from another system, and read its discussion.

Use this to keep a ticket's discussion in one place: post a build result or a customer reply to
the ticket, or copy its comments somewhere your team already reads. By the end you'll have
posted a comment and read back the ticket's whole thread.

**Before you start:** posting needs a **Board — read & write** key (see
[Scopes](https://docs.bydawn.com/developers/authentication/#scopes)), and you need the ticket's `id`, for example from
[Create a ticket](https://docs.bydawn.com/developers/recipes/create-ticket/). Pass the ID as the recipe's argument.

### Swap your key for an access token

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

### Post a comment

`POST /tickets/{ticketId}/comments` with a `body` posts the comment. It shows your key as its
author, so people can tell it came from your integration rather than a colleague.

### Read the comments back

`GET` on the same path returns the ticket's comments, oldest first, so they read like a
conversation. The recipe checks that its own comment is there.

The whole recipe:

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

```ts
// Comment on a ticket, then read the ticket's comments back.
// 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 comment.ts <ticketId>
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. Post a comment on the ticket you named.
const ticketId = process.argv[2];
if (!ticketId) throw new Error('Usage: node comment.ts <ticketId>');
const url = `${api}/tickets/${encodeURIComponent(ticketId)}/comments`;

const create: Schemas['CommentCreate'] = { body: 'Posted by the comment recipe through the SAW API.' };
const comment = await call<Schemas['Comment']>(url, { method: 'POST', body: create, token });
console.log(`Posted comment ${comment.id}.`);

// 3. Read the ticket's comments back, oldest first.
const comments = await call<Schemas['Comment'][]>(url, { token });
console.log(`The ticket has ${comments.length} comment(s):`);
for (const c of comments) console.log(`  ${c.createdAt}  ${c.body}`);

if (!comments.some((c) => c.id === comment.id)) throw new Error('The new comment is missing from the list.');
```

Run it:

```sh
SAW_API_KEY=saw_live_… node --experimental-strip-types comment.ts <ticketId>
```

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

```python
# Comment on a ticket, then read the ticket's comments back.
# Standard library only (urllib and json), Python 3.9 or later.
#
#   SAW_API_KEY=saw_live_… python3 comment.py <ticketId>
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. Post a comment on the ticket you named.
if len(sys.argv) < 2:
    sys.exit("Usage: python3 comment.py <ticketId>")
url = f"{api}/tickets/{urllib.parse.quote(sys.argv[1], safe='')}/comments"

comment = call(url, "POST", {"body": "Posted by the comment recipe through the SAW API."}, token)
print(f"Posted comment {comment['id']}.")

# 3. Read the ticket's comments back, oldest first.
comments = call(url, token=token)
print(f"The ticket has {len(comments)} comment(s):")
for c in comments:
    print(f"  {c['createdAt']}  {c['body']}")

if not any(c["id"] == comment["id"] for c in comments):
    sys.exit("The new comment is missing from the list.")
```

Run it:

```sh
SAW_API_KEY=saw_live_… python3 comment.py <ticketId>
```

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

```java
// Comment on a ticket, then read the ticket's comments back.
// 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 Comment.java <ticketId>
//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.List;
import java.util.Map;

public class Comment {
  /** 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 TicketComment(String id, String body, String createdAt) {}

  /** 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 {
    if (args.length < 1) throw new IllegalArgumentException("Usage: jbang Comment.java <ticketId>");

    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. Post a comment on the ticket you named.
    String url = api + "/tickets/" + URLEncoder.encode(args[0], StandardCharsets.UTF_8) + "/comments";

    TicketComment comment = call("POST", url, token, Map.of("body", "Posted by the comment recipe through the SAW API."),
        new TypeReference<Envelope<TicketComment>>() {});
    System.out.println("Posted comment " + comment.id() + ".");

    // 3. Read the ticket's comments back, oldest first.
    List<TicketComment> comments = call("GET", url, token, null, new TypeReference<Envelope<List<TicketComment>>>() {});
    System.out.println("The ticket has " + comments.size() + " comment(s):");
    for (TicketComment c : comments) System.out.println("  " + c.createdAt() + "  " + c.body());

    if (comments.stream().noneMatch(c -> c.id().equals(comment.id()))) throw new IllegalStateException("The new comment is missing from the list.");
  }

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

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

```csharp
// Comment on a ticket, then read the ticket's comments back.
// Plain HttpClient and System.Net.Http.Json, both part of .NET: no NuGet packages.
//
//   SAW_API_KEY=saw_live_… dotnet run comment.cs -- <ticketId>
//
// `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;

if (args.Length < 1) throw new ArgumentException("Usage: dotnet run comment.cs -- <ticketId>");
var ticketId = Uri.EscapeDataString(args[0]);

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. Post a comment on the ticket you named.
var comment = await ReadAsync<Comment>(await http.PostAsJsonAsync($"tickets/{ticketId}/comments",
    new { body = "Posted by the comment recipe through the SAW API." }));
Console.WriteLine($"Posted comment {comment.Id}.");

// 3. Read the ticket's comments back, oldest first.
var comments = await ReadAsync<List<Comment>>(await http.GetAsync($"tickets/{ticketId}/comments"));
Console.WriteLine($"The ticket has {comments.Count} comment(s):");
// `createdAt` is printed as the API sends it, an ISO 8601 string.
foreach (var c in comments) Console.WriteLine($"  {c.CreatedAt}  {c.Body}");

if (!comments.Any(c => c.Id == comment.Id)) throw new InvalidOperationException("The new comment is missing from the list.");

// 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 Comment(string Id, string Body, string CreatedAt);

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

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

```go
// Comment on a ticket, then read the ticket's comments back.
// Standard library only (net/http and encoding/json).
//
//	SAW_API_KEY=saw_live_… go run comment.go <ticketId>
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 comment struct {
	ID        string `json:"id"`
	Body      string `json:"body"`
	CreatedAt string `json:"createdAt"`
}

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. Post a comment on the ticket you named.
	if len(os.Args) < 2 {
		fail("Usage: go run comment.go <ticketId>")
	}
	commentsURL := api + "/tickets/" + url.PathEscape(os.Args[1]) + "/comments"

	posted, err := call[comment](http.MethodPost, commentsURL, token, map[string]string{
		"body": "Posted by the comment recipe through the SAW API.",
	})
	check(err)
	fmt.Printf("Posted comment %s.\n", posted.ID)

	// 3. Read the ticket's comments back, oldest first.
	comments, err := call[[]comment](http.MethodGet, commentsURL, token, nil)
	check(err)
	fmt.Printf("The ticket has %d comment(s):\n", len(comments))
	found := false
	for _, c := range comments {
		fmt.Printf("  %s  %s\n", c.CreatedAt, c.Body)
		found = found || c.ID == posted.ID
	}

	if !found {
		fail("The new comment is missing from the list.")
	}
}

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

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

```bash
#!/usr/bin/env bash
# Comment on a ticket, then read the ticket's comments back.
# Needs curl and jq: jq builds the request bodies and reads the responses.
#
#   SAW_API_KEY=saw_live_… bash comment.sh <ticketId>
set -euo pipefail

ticket_id=${1:?Usage: bash comment.sh <ticketId>}

: "${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. Post a comment on the ticket you named.
url="$api/tickets/$(jq -rn --arg id "$ticket_id" '$id | @uri')/comments"

comment=$(jq -n '{body: "Posted by the comment recipe through the SAW API."}' |
  curl -sS -X POST "$url" -H "Authorization: Bearer $token" -H 'Content-Type: application/json' --data @- |
  jq "$DATA")
echo "Posted comment $(jq -r .id <<<"$comment")."

# 3. Read the ticket's comments back, oldest first.
comments=$(curl -sS "$url" -H "Authorization: Bearer $token" | jq "$DATA")
echo "The ticket has $(jq length <<<"$comments") comment(s):"
jq -r '.[] | "  \(.createdAt)  \(.body)"' <<<"$comments"

if [ "$(jq --arg id "$(jq -r .id <<<"$comment")" 'any(.id == $id)' <<<"$comments")" != true ]; then
  echo 'The new comment is missing from the list.' >&2
  exit 1
fi
```

Run it:

```sh
SAW_API_KEY=saw_live_… bash comment.sh <ticketId>
```

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

```text
Posted comment comment-1.
The ticket has 1 comment(s):
  2026-10-09T12:00:02.000Z  Posted by the comment recipe through the SAW API.
```

## Run it in your browser

Run the TypeScript recipe from this page. It posts a comment on the ticket, then lists the ticket's
comments. A ticket you [created here](https://docs.bydawn.com/developers/recipes/create-ticket/) is filled in 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/recipes/comments/).

## Things to know

- **Replies.** Set `parentCommentId` to reply to a comment. Replies go one level deep.
- **Only what's new.** To pick up new comments as they come, pass the newest `createdAt` you've
  seen as `since` (ISO 8601). You get only the comments created after it.
- **Your own comments.** A key can edit (`PATCH`) and delete (`DELETE …/comments/{commentId}`)
  the comments it posted, and no one else's.

Source: https://docs.bydawn.com/developers/recipes/comments/
