A comment belongs to one audience: shared (everyone who sees the ticket), internal (workspace users and elevated client users on the project), or staff (workspace users only). Attachments carry an audience too. Comments cannot be edited or deleted. Upload files first, then reference their ids in the comment.
POST /api/w/{slug}/tickets/{ref}/comments
Add a comment. Token: yes, write. The caller may write to an audience they can read; the ticket page's permissions.comment lists them and permissions.defaultAudience is the default. Mentions are applied as viewer grants before the comment is stored; a mention the caller may not share with aborts the whole comment.
Body field (AddComment) |
Type | Required | Validation |
|---|---|---|---|
body |
string | yes | Markdown, at most 50 000 characters; may be empty only when attachments are given |
audience |
shared, internal, staff |
no | Defaults to permissions.defaultAudience |
mentions |
uuid[] | no | At most 20 identity ids; each becomes a viewer |
attachmentIds |
uuid[] | no | At most 20 attachments already uploaded to this ticket |
Request
curl -X POST https://saq.no/api/w/acme/tickets/ACME-184/comments \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"body": "Deployed the fix to staging; please verify.",
"audience": "shared",
"attachmentIds": ["01924c3e-0a11-7b22-8c33-4d5e6f7a8b9c"]
}'
Response 201
{
"id": "01924c3e-0b12-7c23-9d34-5e6f7a8b9c0d",
"audience": "shared",
"body": "Deployed the fix to staging; please verify.",
"bodyHtml": "<p>Deployed the fix to staging; please verify.</p>",
"createdAt": "2026-09-19T10:20:00.000Z",
"mentioned": []
}
mentioned lists the identity ids that became viewers. On the ticket page the full Comment has id, audience, author (id, name, clientName) or null, contact (email, name) or null for email correspondents, body, bodyHtml, source, createdAt, and editedAt (always null; comments are not editable).
| Error | Status | details.reason |
|---|---|---|
forbidden |
403 | comment_audience (with audience): the caller cannot write at that audience; share_forbidden (with identityId, name): a mention was refused |
invalid |
400 | body_required; body_length; attachment_ticket_mismatch |
not_found |
404 | Ticket not visible, or a mentioned person unknown (unknown_person) |
Rendered HTML is sanitised: no scripts, styles, or remote images; links open in a new tab with nofollow noopener.
POST /api/w/{slug}/tickets/{ref}/attachments
Upload one file to a ticket. Token: yes, write. Budget: 30 uploads per minute per identity. The request is multipart/form-data.
| Form field | Required | Meaning |
|---|---|---|
file |
yes | The file; at most 20 MiB and not empty |
audience |
no | shared, internal, or staff; otherwise the ticket's default audience for the caller |
Request
curl -X POST https://saq.no/api/w/acme/tickets/ACME-184/attachments \
-H 'Authorization: Bearer saq_YOUR_TOKEN' \
-F 'file=@screenshot.png' \
-F 'audience=internal'
Response 201 (Attachment)
{
"id": "01924c3e-0a11-7b22-8c33-4d5e6f7a8b9c",
"filename": "screenshot.png",
"contentType": "image/png",
"size": 48213,
"inline": true,
"audience": "internal",
"commentId": null,
"uploadedBy": "01924c3e-0004-7000-8000-000000000001",
"createdAt": "2026-09-19T10:18:00.000Z"
}
The declared content type is never trusted: the bytes are sniffed. inline is true only for PNG, JPEG, GIF, WebP, AVIF, and PDF; SVG and HTML are never inline. Text-like files that do not sniff are stored as text/plain; anything else as application/octet-stream. commentId is set once a comment references the attachment.
| Error | Status | Message and details |
|---|---|---|
invalid |
400 | "A file is required" (field: "file"); "The file is empty"; "Attachments are limited to 20 MiB" |
invalid |
400 | "That kind of file is not accepted" with reason: executable (sniffed executable or installer), script (extension such as exe, bat, ps1, js, sh, jar, msi, dmg, apk), mismatch (declared image or PDF whose bytes differ) |
forbidden |
403 | The caller may not attach at that audience |
rate_limited |
429 | More than 30 uploads per minute |
not_found |
404 | Ticket not visible |
GET /api/w/{slug}/attachments/{id}
Download an attachment's bytes. Token: yes, read. The caller must see the ticket and be able to read the attachment's audience; otherwise 404.
Request
curl -L -o screenshot.png \
https://saq.no/api/w/acme/attachments/01924c3e-0a11-7b22-8c33-4d5e6f7a8b9c \
-H 'Authorization: Bearer saq_YOUR_TOKEN'
Response 200 with the file body and these headers:
| Header | Value |
|---|---|
Content-Type |
The sniffed type for inline attachments; application/octet-stream for everything else |
Content-Length |
Size in bytes |
Content-Disposition |
inline or attachment, with filename and filename* |
Cache-Control |
private, no-store |
X-Content-Type-Options |
nosniff |
Content-Security-Policy |
default-src 'none'; sandbox |
| Error | Status | When |
|---|---|---|
not_found |
404 | Unknown id, ticket not visible, or audience not readable |
POST /api/w/{slug}/render
Preview markdown through the same sanitiser that stores comments and descriptions. Token: no (browser session only); the app's composer uses it.
Body: { "body": "**bold**" } (at most 50 000 characters). Response 200 { "html": "<p><strong>bold</strong></p>" }.