# Test case review requests API

> Send a new test case or a change to an existing one for review instead of saving it, list and read review requests, and approve, edit, decline or merge them.

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](https://docs.probara.net/en/developers/api/project-review-settings/)).

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

## Send a change for review

```http
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.

```json
{
  "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`.

```json
{
  "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`.

```json
{ "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](#notifications)).

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

### 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

```http
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:

```http
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`.

```json
{
  "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](#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

```http
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`).

| 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

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

```json
{
  "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.

```json
{
  "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

```http
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.

## 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

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

```json
{ "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

```json
{ "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

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`.

### Merge

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

| 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

```http
POST /api/v1/projects/{projectId}/test-case-reviews/bulk-decision
```

```json
{
  "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`.

```json
{
  "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

```http
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](https://docs.probara.net/en/developers/api/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

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

Review actions notify the people involved, as notifications whose `entityType` is
`test_case_review` (see the [Notifications API](https://docs.probara.net/en/developers/api/notifications/#kinds)):

| `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

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](https://docs.probara.net/en/developers/api-reference/#mandatory-test-case-review-202-and-review_required).

## MCP

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](https://docs.probara.net/en/developers/mcp-tools/).
