Skip to content

Test case review requests API

A review request proposes a change to a project’s repository: a new test case (create), a change to an existing one (update), or archiving (archive) or deleting (delete) an existing one. Nothing in the repository changes when the request is sent; the proposal is applied when the request is merged.

Test case review is included in the Pro and Enterprise plans (and available during a trial). On other plans these endpoints, and their MCP tools, answer 403 forbidden; GET /api/v1/orgs/current carries caseReviewEnabled so you can check first. The project must have review turned on (Test case review settings).

{projectId} is the project code (for example ACME).

POST /api/v1/projects/{projectId}/test-case-reviews

Requires the test-cases.write permission, the same as a direct save (held today by owner, admin and member).

A new case: case is the body POST /api/v1/projects/{projectId}/test-cases takes.

{
"kind": "create",
"case": {
"title": "Login works with SSO",
"steps": [{ "position": 1, "action": "Open the login page" }]
}
}

A change to an existing case: changes is the body PATCH /api/v1/test-cases/{caseUlid} takes. Its expectedRevision is the case revision you made the change on; omitted, the case’s current revision is used. Either way it is recorded as the request’s baseRevision.

{
"kind": "update",
"caseUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F3",
"changes": { "patch": { "title": "Login works with SSO" }, "expectedRevision": 4 },
"reviewerUserIds": ["01J8Z3W4X5Y6Z7A8B9C0D1E2F4"]
}

Archiving or deleting an existing case: kind archive or delete and the caseUlid. The case’s current revision is recorded as baseRevision; if the case is saved (or, for archive, archived) before the request is merged, the merge answers 409 with details.reason review_conflict. These requests cannot be edited. Proposing to archive a case that is already archived answers 409.

{ "kind": "archive", "caseUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F3" }
  • reviewerUserIds (optional, at most 50) names the reviewers. Omitted, the project’s default reviewers are assigned. The author is never a reviewer of their own request. Every reviewer must be a current member with access to the project whose role holds the test-cases.review permission; otherwise the request answers 422 validation_failed with details.reviewerUserIds listing the ULIDs that were refused, and nothing is saved.
  • Images staged with POST /api/v1/images:stage can be referenced in the proposal’s rich-text fields like in a save. They are kept with the request for as long as it is open (the staged upload itself expires after about two days, the request’s copy does not).
  • A new case’s suiteUlid and milestoneUlid must belong to the project, as for a direct create; otherwise the request answers 422 validation_failed and nothing is saved.
  • A submission records a test_case_review.created activity entry and notifies the assigned reviewers (see Notifications).

The response is 201 with the request, as GET returns it.

Statusdetails.reasonWhen
403—The plan does not include test case review, or the role cannot write test cases.
404—The project, or the case of an update, does not exist.
409review_not_enabledThe project does not have review turned on.
409review_already_openThe case already has an open request. details.reviewUlid and details.displayId name it.
409stale_revisionchanges.expectedRevision is not the case’s current revision (details.currentRevision is). Read the case again.
409—The case is synced from Git (test_case_synced_read_only): its scenario changes in its .feature file, not through review. A bulk request that names a synced case is refused whole, and details.caseUlids lists the synced cases.
422—An invalid proposal, a suite or milestone of another project, or an ineligible reviewer.
GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}

Open to anyone who can read the project.

To address a request by the RV-{n} id shown in the app (and in its URL, such as /projects/ACME/review/RV-3), use:

GET /api/v1/projects/{projectId}/test-case-reviews/by-display-id/{displayId}

It answers the same body, ulid included. A displayId that is not RV-{n} answers 422; an unknown one, 404.

{
"ulid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F5",
"displayId": "RV-3",
"kind": "update",
"status": "open",
"title": "Login works with SSO",
"caseUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F3",
"case": {
"ulid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F3",
"displayId": "ACME-12",
"title": "Login works"
},
"baseRevision": 4,
"proposalVersion": 1,
"proposal": {
"kind": "update",
"changes": { "patch": { "title": "Login works with SSO" }, "expectedRevision": 4 }
},
"author": {
"kind": "user",
"ulid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F6",
"displayName": "Ada Lovelace",
"avatarKey": null
},
"reviewers": [
{
"userUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F4",
"displayName": "Grace Hopper",
"avatarKey": null,
"decision": "pending",
"proposalVersion": null,
"current": false,
"note": null,
"decidedAt": null
}
],
"approvalCount": 0,
"approvalsRequired": 1,
"allowSelfMerge": false,
"conflict": false,
"currentVersionOnly": false,
"declineNote": null,
"declineReason": null,
"createdAt": 1760400000000,
"updatedAt": 1760400000000,
"closedAt": null
}
FieldMeaning
displayIdRV-{n}, numbered per project.
kindcreate (a new case), update (a change to caseUlid), archive or delete (archiving or deleting caseUlid).
statusopen, merged or declined.
titleThe case title as the proposal leaves it.
baseRevisionThe case revision an update, archive or delete was proposed on; null for create.
proposalVersionStarts at 1 and goes up each time the proposal is edited.
proposalThe body that is applied when the request is merged. Staged images name the request’s kept copies.
reviewersEach reviewer’s decision (pending, approved, changes_requested) and the version it was made on.
caseThe case caseUlid names (its displayId and current title); null for an unmerged create and once the case was deleted.
deletedCaseThe case’s displayId and title when it was deleted (by a merged delete or directly); null while it exists. caseUlid is then null.
approvalCountApprovals on the current proposalVersion. The author’s own approval never counts.
approvalsRequiredApprovals the project requires before the request can be merged (0: none).
allowSelfMergeWhether the project lets the author merge their own request.
conflicttrue while an open update, archive or delete cannot be merged because the case changed after it was proposed (or an archive’s case is already archived).
currentVersionOnlytrue for an update that proposes no change: it asks for the case to be approved as it is. See Send cases for review as they are.
declineNoteThe note left when the request was declined; null otherwise.
declineReasonWhy the system declined the request on its own: case_deleted when its test case was deleted. null when a person declined it, or it is not declined.

A reviewer’s current is false when their decision was made on an earlier proposal version: it stays visible but no longer counts. note is the note they left with it. A merged create names the case it created in caseUlid and case.

GET /api/v1/projects/{projectId}/test-case-reviews?status=open&page=1&pageSize=50

Open to anyone who can read the project. Newest first, each item in the shape above without proposal and allowSelfMerge, in a page (items, page, pageSize, total).

QueryMeaning
statusopen (default), merged, declined or all.
kindOnly requests of one kind (create, update, archive, delete).
authorOnly requests sent by this member (user ULID).
reviewerOnly requests this member (user ULID) reviews.
assignedToMetrue: only requests you review.
needsMyReviewtrue: only open requests waiting for your decision.
page1-based page; pageSize up to 200, 50 by default.

A request waits for your decision while you are one of its reviewers and have not approved or requested changes on its current proposalVersion: a decision you made before the proposal was edited is outdated and counts as not made.

GET /api/v1/projects/{projectId}/test-case-reviews/summary answers { "enabled": true, "openCount": 3, "needsMyReviewCount": 1 }: whether the project has review turned on, how many requests are open, and how many of them wait for your decision.

POST /api/v1/projects/{projectId}/test-case-reviews/bulk
{
"caseUlids": ["01J8Z3W4X5Y6Z7A8B9C0D1E2F3", "01J8Z3W4X5Y6Z7A8B9C0D1E2F7"],
"reviewerUserIds": ["01J8Z3W4X5Y6Z7A8B9C0D1E2F4"]
}

Opens one update request per case (1 to 200 cases) that proposes no change, so reviewers approve the case as it is: currentVersionOnly is true, proposal.changes holds only expectedRevision, and /versions answers the same case as live and proposed. Merging the request marks the case approved without writing a revision; if the case changed since, the merge answers 409 review_conflict. Every request takes reviewerUserIds, or the project’s default reviewers when it is omitted. Same gates as a submission (test-cases.write, review enabled), and one test_case_review.created audit event per request.

{
"created": [
{ "ulid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F8", "displayId": "RV-7", "currentVersionOnly": true }
],
"skipped": [
{
"caseUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F7",
"reason": "review_already_open",
"reviewUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F5",
"displayId": "RV-3"
}
]
}

It answers 201 with created (the requests, in the list’s item shape) and skipped, each with a reason: review_already_open, archived or not_found.

GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/versions

Answers the case as the proposal leaves it (proposed) and, for an update, the case as it is now (live, at liveRevision), both in the shape of a stored case revision (GET /api/v1/test-cases/{caseUlid}/revisions/{revision}’s snapshot), so you can compare them. Nothing is written. A proposal that no longer applies, for example because it names a suite, a milestone, a custom field or a shared step deleted since, answers proposed: null and notApplicable.message explaining why; edit the proposal to fix it.

Every action below works on an open request only; on a merged or declined one it answers 409 with details.reason review_closed. Turning review off in the project does not strand open requests: they can still be approved, edited, declined and merged.

ActionWho
POST .../{reviewUlid}/approveAny member whose role has test-cases.review, except the author.
POST .../{reviewUlid}/unapproveAny member whose role has test-cases.review, for their own approval of the current proposal (409 not_approved otherwise).
POST .../{reviewUlid}/request-changesAny member whose role has test-cases.review, except the author.
PATCH .../{reviewUlid} (edit)The author, or any member whose role has test-cases.review.
PUT .../{reviewUlid}/reviewersThe author, or any member whose role has test-cases.review.
POST .../{reviewUlid}/declineThe author, or any member whose role has test-cases.review.
POST .../{reviewUlid}/mergeAny member whose role has test-cases.review other than the author; the author only with self-merge on.

Each answers the request as it is after the action, and records an audit event: test_case_review.approved, .unapproved, .changes_requested, .edited, .reviewers_updated, .declined or .merged.

Send {} or { "note": "Add the logout step" } (up to 2000 characters). The decision is made on the current proposalVersion. Doing it makes you a reviewer if you were not one.

When the project has auto-merge on, an approval that brings the current proposal to the required approvals, with no reviewer asking for changes on it, merges the request right away, with you as the one who merged it (the merged event carries "auto": true). If that merge cannot go through (the case changed, say), the request stays open with your approval. The response carries autoMergeFailure: null, or { "message": "…" } when the auto-merge failed for an unexpected reason (the approval is still recorded and the request stays open; merge it again).

{ "kind": "update", "changes": { "patch": { "title": "Login works with SSO and MFA" } } }

The body has the request’s kind and its case (for create) or changes (for update), as it was submitted. It becomes the next proposalVersion, made on the case as it is now: baseRevision moves to the case’s current revision, which also resolves a conflict. Decisions made on earlier versions stop counting. New staged images are kept with the request; images the edit drops are released. An archive or delete request has nothing to edit: editing it answers 422.

{ "reviewerUserIds": ["01J8Z3W4X5Y6Z7A8B9C0D1E2F4"] }

The whole list. A reviewer you keep keeps their decision; one you leave out is removed with it. A new reviewer must be eligible as at submission, or the call answers 422 with details.reviewerUserIds.

Closes the request as declined. The case stays as it is, and the images the proposal added are released. The body may be omitted, or carry { "note": "Duplicate of ACME-4" } (up to 2000 characters), shown on the request as declineNote.

Applies the proposal like a direct save: a create creates the case, an update saves the case as a new revision whose source is review. It records the case’s own test_case.created or test_case.updated audit event as well. Images are charged to the organization’s storage like a save, so a merge can answer 413 when storage is full.

Merging needs the required approvals on the current proposal (any time when the project requires none). The author can merge only when the project allows self-merge, under the same rule.

A request with currentVersionOnly writes nothing to the case: the case becomes approved and keeps its revision. An archive request archives the case (test_case.archived; the case keeps its review status from before the request) and a delete request deletes it (test_case.deleted; it answers 409 when the case has results, like a direct delete).

The case write and the closing of the request happen together: a merge that fails leaves the case and the request as they were.

Statusdetails.reasonWhen
403—The plan does not include test case review, or you may not take this action (see the table above).
404—The request is not in the project.
409review_closedThe request is already merged or declined (details.status).
409review_conflictMerge only: the case changed after the proposal was made. Edit the proposal, then merge.
409approvals_requiredMerge only: the current proposal has details.approvalCount of details.approvalsRequired approvals.
409—The request changed while the action ran (an edit or another action landed first): read it and try again.
413—Merge only: the images would exceed the organization’s storage.
422—An invalid body, an edit of another kind, an ineligible reviewer, or a proposal that no longer applies.
POST /api/v1/projects/{projectId}/test-case-reviews/bulk-decision
{
"reviewUlids": ["01J8Z3W4X5Y6Z7A8B9C0D1E2F5", "01J8Z3W4X5Y6Z7A8B9C0D1E2F8"],
"decision": "request_changes",
"note": "Add the expected result to every step"
}

decision is approve, request_changes or decline, taken on each request (1 to 50) in order through the single action above: the same rules, audit events, notifications and auto-merge. note is required for request_changes and decline (422 without it) and optional for approve; it is left on every request, as the reviewer’s decision note or as declineNote.

{
"done": [{ "reviewUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F5", "displayId": "RV-3", "status": "open" }],
"skipped": [
{
"reviewUlid": "01J8Z3W4X5Y6Z7A8B9C0D1E2F8",
"httpStatus": 403,
"reason": null,
"message": "the author cannot review their own request"
}
]
}

done lists the requests the decision was applied to, with their status after it (merged when an approval auto-merged one) and autoMergeFailure (as on a single approval). A request the single action would refuse is listed in skipped with that refusal’s httpStatus, details.reason and message; the others still go through.

GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments
POST /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments
PATCH /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments/{commentUlid}
DELETE /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments/{commentUlid}
POST /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments/{commentUlid}/pin
DELETE /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments/{commentUlid}/pin

The discussion on a request uses the same bodies, responses and rules as the test case comments: replies, @[userUlid] mentions, image attachments and pins. Anyone who can read the project lists the comments; members and above post, edit, delete and pin them, on open and closed requests alike. A request of another project answers 404, and the plan gate answers 403 like the other review endpoints.

The activity entries are test_case_review.commented, test_case_review.comment_updated, test_case_review.comment_deleted, test_case_review.comment_pinned and test_case_review.comment_unpinned.

Deleting a case (DELETE /test-cases/{caseUlid}, or merging a delete request) keeps its review requests and their comments. Its open request is declined with declineNote "The test case was deleted." (a test_case_review.declined entry with "caseDeleted": true, and the decided notification). Every request about the case then has caseUlid and case null and deletedCase set.

Review actions notify the people involved, as notifications whose entityType is test_case_review (see the Notifications API):

kindSent toOn
test_case_review.assignedThe reviewers a submission or a reviewers change addsPOST /test-case-reviews, PUT …/reviewers
test_case_review.decidedThe participantsapprove, request-changes, decline, merge (and an auto-merge)
test_case_review.editedThe participantsPATCH …/{reviewUlid}
test_case_review.comment_createdThe participants, except those the comment mentionsPOST …/comments
test_case_review.comment_mentionThe members a comment mentions, or an edit of a comment newly mentionsPOST …/comments, PATCH …/comments/{commentUlid}

The participants are the request’s author, its reviewers and everyone who commented on it. The caller is never notified of their own action, and only members who can still read the project are. An API token’s actions notify the same people.

A bulk action on two or more requests sends one notification per recipient for the whole batch, with entityType test_case_review_batch and eventCount the number of the batch’s requests that concern the recipient: test_case_review.assigned for POST .../bulk (linking to /projects/{code}/review?status=assigned) and test_case_review.decided for POST .../bulk-decision (with the decision in payload.context.status, linking to the Review list).

While the project’s review is mandatory, POST .../test-cases and the consolidated PATCH /test-cases/{caseUlid} open a request with this endpoint’s rules and answer 202 with it (the project’s default reviewers are assigned), and an import opens one request per case. POST /test-cases/{caseUlid}/archive, DELETE /test-cases/{caseUlid} and POST .../test-cases/bulk-archive open archive and delete requests the same way. The other direct case writes answer 409 conflict with details.reason review_required and write nothing. See Mandatory test case review.

Every endpoint on this page has an MCP tool, with the same rules and refusals:

ToolEndpoint
create_test_case_reviewPOST .../test-case-reviews (kind, then case, or case_ulid and changes)
get_test_case_reviewGET .../{reviewUlid}
get_test_case_review_by_display_idGET .../by-display-id/{displayId}
list_test_case_reviewsGET .../test-case-reviews
get_test_case_review_versionsGET .../{reviewUlid}/versions
approve_test_case_reviewPOST .../{reviewUlid}/approve
unapprove_test_case_reviewPOST .../{reviewUlid}/unapprove
request_changes_test_case_reviewPOST .../{reviewUlid}/request-changes
update_test_case_reviewPATCH .../{reviewUlid}
set_test_case_review_reviewersPUT .../{reviewUlid}/reviewers
decline_test_case_reviewPOST .../{reviewUlid}/decline
merge_test_case_reviewPOST .../{reviewUlid}/merge
bulk_create_test_case_reviewsPOST .../test-case-reviews/bulk
bulk_decide_test_case_reviewsPOST .../test-case-reviews/bulk-decision

Under mandatory review, create_test_case, update_test_case, archive_test_case and delete_test_case return { "sentToReview": true, "message": "…", "review": { … } } instead of acting, and bulk_archive_test_cases returns { "sentToReview": true, "message": "…", "created", "skipped" }. See MCP tools.