Comments and attachments

Add comments, upload and download attachments, and preview markdown through the SAQ REST API, with audiences, limits, and refusal reasons.

Written for
integration-developer
Roles
owner, admin, member, elevated-client-user, agent
Requires
API token with write scope to comment or upload; read scope to download; Markdown preview: browser session only
Feature
tickets

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