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).
Send a change for review
Section titled “Send a change for review”POST /api/v1/projects/{projectId}/test-case-reviewsRequires 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 thetest-cases.reviewpermission; otherwise the request answers422 validation_failedwithdetails.reviewerUserIdslisting the ULIDs that were refused, and nothing is saved.- Images staged with
POST /api/v1/images:stagecan 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
suiteUlidandmilestoneUlidmust belong to the project, as for a direct create; otherwise the request answers422 validation_failedand nothing is saved. - A submission records a
test_case_review.createdactivity entry and notifies the assigned reviewers (see Notifications).
The response is 201 with the request, as GET returns it.
Refusals
Section titled “Refusals”| Status | details.reason | When |
|---|---|---|
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. |
409 | review_not_enabled | The project does not have review turned on. |
409 | review_already_open | The case already has an open request. details.reviewUlid and details.displayId name it. |
409 | stale_revision | changes.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. |
Read a review request
Section titled “Read a review request”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}| Field | Meaning |
|---|---|
displayId | RV-{n}, numbered per project. |
kind | create (a new case), update (a change to caseUlid), archive or delete (archiving or deleting caseUlid). |
status | open, merged or declined. |
title | The case title as the proposal leaves it. |
baseRevision | The case revision an update, archive or delete was proposed on; null for create. |
proposalVersion | Starts at 1 and goes up each time the proposal is edited. |
proposal | The body that is applied when the request is merged. Staged images name the request’s kept copies. |
reviewers | Each reviewer’s decision (pending, approved, changes_requested) and the version it was made on. |
case | The case caseUlid names (its displayId and current title); null for an unmerged create and once the case was deleted. |
deletedCase | The case’s displayId and title when it was deleted (by a merged delete or directly); null while it exists. caseUlid is then null. |
approvalCount | Approvals on the current proposalVersion. The author’s own approval never counts. |
approvalsRequired | Approvals the project requires before the request can be merged (0: none). |
allowSelfMerge | Whether the project lets the author merge their own request. |
conflict | true 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). |
currentVersionOnly | true 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. |
declineNote | The note left when the request was declined; null otherwise. |
declineReason | Why 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.
List review requests
Section titled “List review requests”GET /api/v1/projects/{projectId}/test-case-reviews?status=open&page=1&pageSize=50Open 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).
| Query | Meaning |
|---|---|
status | open (default), merged, declined or all. |
kind | Only requests of one kind (create, update, archive, delete). |
author | Only requests sent by this member (user ULID). |
reviewer | Only requests this member (user ULID) reviews. |
assignedToMe | true: only requests you review. |
needsMyReview | true: only open requests waiting for your decision. |
page | 1-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.
Send cases for review as they are
Section titled “Send cases for review as they are”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.
Read the proposed version
Section titled “Read the proposed version”GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/versionsAnswers 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.
Act on a request
Section titled “Act on a request”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.
| Action | Who |
|---|---|
POST .../{reviewUlid}/approve | Any member whose role has test-cases.review, except the author. |
POST .../{reviewUlid}/unapprove | Any member whose role has test-cases.review, for their own approval of the current proposal (409 not_approved otherwise). |
POST .../{reviewUlid}/request-changes | Any 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}/reviewers | The author, or any member whose role has test-cases.review. |
POST .../{reviewUlid}/decline | The author, or any member whose role has test-cases.review. |
POST .../{reviewUlid}/merge | Any 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.
Approve or request changes
Section titled “Approve or request changes”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).
Edit the proposal
Section titled “Edit the proposal”{ "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.
Change the reviewers
Section titled “Change the reviewers”{ "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.
Decline
Section titled “Decline”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.
Refusals
Section titled “Refusals”| Status | details.reason | When |
|---|---|---|
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. |
409 | review_closed | The request is already merged or declined (details.status). |
409 | review_conflict | Merge only: the case changed after the proposal was made. Edit the proposal, then merge. |
409 | approvals_required | Merge 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. |
Decide on several requests
Section titled “Decide on several requests”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.
Comments on a request
Section titled “Comments on a request”GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/commentsPOST /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/commentsPATCH /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}/pinDELETE /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments/{commentUlid}/pinThe 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.
When a case is deleted
Section titled “When a case is deleted”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.
Notifications
Section titled “Notifications”Review actions notify the people involved, as notifications whose entityType is
test_case_review (see the Notifications API):
kind | Sent to | On |
|---|---|---|
test_case_review.assigned | The reviewers a submission or a reviewers change adds | POST /test-case-reviews, PUT …/reviewers |
test_case_review.decided | The participants | approve, request-changes, decline, merge (and an auto-merge) |
test_case_review.edited | The participants | PATCH …/{reviewUlid} |
test_case_review.comment_created | The participants, except those the comment mentions | POST …/comments |
test_case_review.comment_mention | The members a comment mentions, or an edit of a comment newly mentions | POST …/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).
Mandatory review
Section titled “Mandatory review”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:
| Tool | Endpoint |
|---|---|
create_test_case_review | POST .../test-case-reviews (kind, then case, or case_ulid and changes) |
get_test_case_review | GET .../{reviewUlid} |
get_test_case_review_by_display_id | GET .../by-display-id/{displayId} |
list_test_case_reviews | GET .../test-case-reviews |
get_test_case_review_versions | GET .../{reviewUlid}/versions |
approve_test_case_review | POST .../{reviewUlid}/approve |
unapprove_test_case_review | POST .../{reviewUlid}/unapprove |
request_changes_test_case_review | POST .../{reviewUlid}/request-changes |
update_test_case_review | PATCH .../{reviewUlid} |
set_test_case_review_reviewers | PUT .../{reviewUlid}/reviewers |
decline_test_case_review | POST .../{reviewUlid}/decline |
merge_test_case_review | POST .../{reviewUlid}/merge |
bulk_create_test_case_reviews | POST .../test-case-reviews/bulk |
bulk_decide_test_case_reviews | POST .../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.