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. See Comment attachments for the image attachment workflow.
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.", "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]", "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": []}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 | yes | Non-empty string, max 5000 characters. Whitespace-only values are rejected. May contain @[ulid] mention tokens (see Mentions). |
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). |
attachments | no | See Comment attachments. |
Errors:
422 validation_failed—bodyis empty, whitespace-only, exceeds 5000 characters, orparentUlidpoints to a comment that is itself a reply (reply-to-reply is rejected).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.", "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 (first 240 characters 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 | yes | Replacement body, same validation as POST. |
attachments | no | Full desired attachment list; see Comment attachments. |
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).
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, purging each cascaded reply’s attachments and refunding storage before the rows are removed.
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). On create/edit, the server:
- Extracts every
@[ulid]token from the body. - Resolves each token 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 first, along with their attachments. No additional API call is required; the cascade is atomic within the delete handler.
Examples
Section titled “Examples”# List the newest-first page of comments on a test casecurl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://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://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://probara.net/api/v1/test-cases/01J...CASE/comments"
# Pin a commentcurl -sS -X POST \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://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://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://probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"
# Delete a commentcurl -sS -X DELETE \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://probara.net/api/v1/test-cases/01J...CASE/comments/01J...CMT"