Test case comments API
Test case comments are attached to a specific test case using a (entity_type='test_case', entity_id) generic comments store, shared with defect comments — both surfaces are structurally identical, differing only in the entity path. Comments are scoped to the organization via the test case ULID.
Comments support one level of threaded replies, pinning, and @mention resolution. A body is rich text: send it as Markdown in body, or as a document in bodyDocument. Images sit inline in the body, staged with POST /api/v1/images:stage; a comment has no attachment list.
Endpoints
Section titled “Endpoints”| Method | Path | Description |
|---|---|---|
GET | /api/v1/test-cases/{caseUlid}/comments | List top-level comments (cursor pagination) |
POST | /api/v1/test-cases/{caseUlid}/comments | Add a comment, or a reply via parentUlid |
PATCH | /api/v1/test-cases/{caseUlid}/comments/{commentUlid} | Edit a comment or reply |
DELETE | /api/v1/test-cases/{caseUlid}/comments/{commentUlid} | Delete a comment (cascades to its replies) |
POST | /api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin | Pin a top-level comment |
DELETE | /api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pin | Unpin a top-level comment |
{caseUlid} must belong to the active organization. Cross-tenant access returns 404 not_found.
Authorization
Section titled “Authorization”All endpoints require an authenticated session or a Bearer API token with at least viewer access.
| Operation | Minimum role |
|---|---|
GET | Viewer |
POST (comment or reply) | Member |
PATCH | Member (own comment only) |
DELETE | Member (own) or Admin/Owner (any) |
POST / DELETE .../pin | Member (any top-level comment) |
GET — list comments
Section titled “GET — list comments”/api/v1/test-cases/{caseUlid}/commentsReturns top-level comments (parentUlid: null) with cursor pagination.
Query parameters
Section titled “Query parameters”| Parameter | Type | Description |
|---|---|---|
cursor | string | ULID cursor from the previous page (nextCursor). |
limit | integer | Page size. Default 50, max 200. |
sort | oldest | newest | Top-level ordering. Default oldest (unchanged public default). Replies always stay chronological under their parent regardless of this value. |
Response (200 OK)
Section titled “Response (200 OK)”{ "items": [ { "ulid": "01J…", "body": "Reproduced on staging with a fresh browser session.", "bodyDocument": { "type": "doc", "attrs": { "version": 1 }, "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Reproduced on staging with a fresh browser session." } ] } ] }, "author": { "ulid": "01J…", "displayName": "Ada Lovelace", "avatarKey": null, "avatarVersion": 0 }, "createdAt": 1700000000000, "editedAt": null, "attachments": [], "parentUlid": null, "pinnedAt": null, "pinnedBy": null, "mentions": [], "replyCount": 1, "replies": [ { "ulid": "01J…RPLY", "body": "Confirmed — same result on my end. @[01J…GRACE]", "bodyDocument": { "type": "doc", "attrs": { "version": 1 }, "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": "Confirmed — same result on my end. " }, { "type": "mention", "attrs": { "id": "01J…GRACE" } } ] } ] }, "author": { "ulid": "01J…", "displayName": "Grace Hopper", "avatarKey": null, "avatarVersion": 0 }, "createdAt": 1700000100000, "editedAt": null, "attachments": [], "parentUlid": "01J…", "pinnedAt": null, "pinnedBy": null, "mentions": [{ "ulid": "01J…GRACE", "displayName": "Grace Hopper" }] } ] } ], "nextCursor": "01J…", "pinned": []}body(Markdown) andbodyDocument(the document) hold the same text. Send either one back on edit, never both.attachmentsis always[]. A comment’s images are inline in its body.authorisnullwhen the member was removed from the organization.editedAtisnullwhen the comment has never been edited.nextCursorisnullon the last page.pinnedis the complete pinned top-level set for this test case, ordered bypinnedAtascending, independent of the paginateditemscursor — render it above the feed. It is identical regardless ofsort.- A reply object never carries
replyCountorreplies— thread depth is capped at one level. - A top-level item’s
repliesarray is chronological ascending and capped at 100 inline entries.
Important: replies do not appear in the flat items[] enumeration — they are only reachable via their parent’s replies[]. This is additive-compatible but not byte-compatible with clients that assumed items[] enumerated every comment: iterate replies on each item if you need reply content, and use replyCount if you only need a count.
POST — add a comment or reply
Section titled “POST — add a comment or reply”/api/v1/test-cases/{caseUlid}/commentsRequest body
Section titled “Request body”| Field | Required | Notes |
|---|---|---|
body | one of | Markdown. Trimmed, at most 5000 characters, and it must name content: whitespace-only values are rejected. May contain @[ulid] mention tokens (see Mentions). |
bodyDocument | one of | The versioned document of the body (see Rich text fields). Send body or bodyDocument, never both. |
parentUlid | no | ULID of an existing top-level comment on the same test case. Creates a depth-1 reply. Omit to create a top-level comment (unchanged behavior). |
Errors:
422 validation_failed— bothbodyandbodyDocumentare sent, neither names content,bodyexceeds 5000 characters, the request names a key this endpoint does not accept (such asattachments), orparentUlidpoints to a comment that is itself a reply (reply-to-reply is rejected). The message names the problem;detailsholds the field that failed.422 validation_failedwithdetails.fieldset tobody— a rich-text refusal.details.codenames the rule the body breaks, for exampletoo_largefor a document over the 256 KiB cap (see Rich text fields).422 validation_failedwithdetails.codeunknown_image— the document names an image that was never staged or has expired.details.ulidslists those images.404 not_found—parentUliddoes not resolve to a top-level comment on this test case (wrong entity or wrong organization).403— viewer role.
Response (201 Created)
Section titled “Response (201 Created)”{ "ulid": "01J…", "body": "Looks good on mobile too.", "bodyDocument": { "type": "doc", "attrs": { "version": 1 }, "content": [ { "type": "paragraph", "content": [{ "type": "text", "text": "Looks good on mobile too." }] } ] }, "author": { "ulid": "01J…", "displayName": "Ada", "avatarKey": null, "avatarVersion": 0 }, "createdAt": 1700000000000, "editedAt": null, "attachments": [], "parentUlid": null, "pinnedAt": null, "pinnedBy": null, "mentions": [], "replyCount": 0, "replies": []}Audit: emits test_case.commented with metadata.excerpt (at most 140 characters of the plain text of the body).
PATCH — edit a comment
Section titled “PATCH — edit a comment”/api/v1/test-cases/{caseUlid}/comments/{commentUlid}Only the comment’s original author may edit it (author-only, regardless of top-level or reply). Admins and owners cannot edit another member’s comment. Mentions are re-resolved from the new body on every edit.
Request body
Section titled “Request body”| Field | Required | Notes |
|---|---|---|
body | one of | Replacement Markdown body, same validation as POST. |
bodyDocument | one of | Replacement document, same validation as POST. Send body or bodyDocument, never both. |
Response (200 OK)
Section titled “Response (200 OK)”Updated comment with editedAt set to the current timestamp and mentions[] reflecting the new body.
Errors: 403 if not the author. 404 if the comment does not exist on this case (wrong caseUlid / wrong org). 422 validation_failed for the same refusals as POST.
Audit: emits test_case.comment_updated with metadata.excerpt.
DELETE — delete a comment
Section titled “DELETE — delete a comment”/api/v1/test-cases/{caseUlid}/comments/{commentUlid}Authors may delete their own comment. Admins and owners may delete any comment. Deleting a top-level comment cascades to delete all of its replies too. Each deleted comment releases the inline images its body holds, so an image no other text or stored revision uses is removed with it.
Response (204 No Content)
Section titled “Response (204 No Content)”Errors: 403 if the caller is a member trying to delete another member’s comment.
Audit: emits test_case.comment_deleted for the deleted comment, plus one additional test_case.comment_deleted per cascaded reply.
POST — pin a comment
Section titled “POST — pin a comment”/api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pinPins a top-level comment so it always appears in the pinned[] array of the list response, rendered ahead of the paginated feed regardless of sort. Any member or above may pin any top-level comment (not just their own). There is no cap on the number of pinned comments per test case.
Errors: 422 validation_failed if commentUlid refers to a reply — only top-level comments can be pinned. 403 for viewer role. 404 if the comment does not exist on this case.
Response (200 OK)
Section titled “Response (200 OK)”Updated comment with pinnedAt (epoch ms) and pinnedBy (actor summary of who pinned it) set.
Audit: emits test_case.comment_pinned.
DELETE — unpin a comment
Section titled “DELETE — unpin a comment”/api/v1/test-cases/{caseUlid}/comments/{commentUlid}/pinClears pinnedAt/pinnedBy. Any member or above may unpin.
Response (200 OK)
Section titled “Response (200 OK)”Updated comment with pinnedAt: null, pinnedBy: null.
Errors: 403 for viewer role. 404 if the comment does not exist on this case.
Audit: emits test_case.comment_unpinned.
Mentions
Section titled “Mentions”Comment bodies may embed @[ulid] tokens referencing an organization member by ULID (the ULID form, not a display-name string, so renames never break a mention). In bodyDocument, the same mention is a mention node whose attrs.id is that ULID. On create/edit, the server:
- Extracts every mention from the body, the
@[ulid]token in Markdown or thementionnode in the document. - Resolves each mention against the active organization’s membership only — a ulid from another organization, or one that doesn’t resolve to a member, is silently left as plain, unstyled text and creates no mention row.
- Persists one row per distinct valid mention, and reconciles the set on every edit (removed mentions are cleared, new ones are added).
The response’s mentions[] array always reflects the mentioned member’s current display name — renaming a member updates every past mention that references them. Mentions never trigger a notification or email.
Cascade delete
Section titled “Cascade delete”When a test case is deleted (DELETE /api/v1/test-cases/{caseUlid}), all comments and replies for that test case are deleted along with the inline images they hold, in the same transaction as the case. No additional API call is required; if the delete is refused, the comments are kept.
Examples
Section titled “Examples”# List the newest-first page of comments on a test casecurl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments?sort=newest"
# Add a top-level commentcurl -sS -X POST \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"Verified on Safari 17 — passes."}' \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments"
# Reply to a top-level comment, mentioning a teammatecurl -sS -X POST \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"Thanks @[01J...GRACE] — confirmed here too.","parentUlid":"01J...CMT"}' \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments"
# Pin a commentcurl -sS -X POST \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT/pin"
# Unpin a commentcurl -sS -X DELETE \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT/pin"
# Edit a commentcurl -sS -X PATCH \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{"body":"Updated note after re-testing."}' \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"
# Delete a commentcurl -sS -X DELETE \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"