Skip to content

Run cases API

GET /api/v1/runs/{runUlid}/cases returns every case that belongs to an open or closed test run. Two fields were added as additive, backward-compatible extensions — existing integrations that ignore unknown keys are unaffected.

GET /api/v1/runs/{runUlid}/cases

Requires viewer+ on the run’s project. Returns 404 when the run is outside the caller’s organization.

Each item in the cases array includes the usual run-case fields plus:

FieldTypeDescription
caseLifecycleStatusobject | nullThe source case’s current (live, not snapshotted) lifecycle status. null when the status field is unset or the source case has been deleted.
customFieldValues[].optionSystemKeystring | nullThe option’s own system key (e.g. status_active, status_draft). null for custom (user-defined) options and for non-select field types.
executedByApiTokenobject | nullThe API token that executed the case’s current attempt (its latest result in the run): { ulid, name, app }, where app is the CI reporter (junit, playwright, jest, cypress, vitest, cucumber) of an organization app token and null for a personal API token. null when the case has no result in the run, when the current attempt was recorded in the web app, and once the token is deleted.
caseRevisionnumber | nullThe revision of the source case this run case froze: its title, fields and steps are that version of the case. null only for a run case created before revisions existed whose case was already deleted.
caseCurrentRevisionnumber | nullThe source case’s current revision (currentRevision on the test case). null when the case was deleted.
caseUpdatedbooleantrue when the run is open, the run case is pending (untested, or retested after a retry) and the case changed after this run case froze it (caseCurrentRevision > caseRevision). Always false for a completed run case and in a closed run.
canApplyLatestbooleantrue when caseUpdated is true and the current attempt has no step results yet, so apply latest version can update it.

A CI reporter writes with an app token, which is no member, so a case it reports keeps assigneeUlid: null unless a member is assigned (for example by the report’s assignFailedTo). executedByApiToken only says which token executed the current attempt, and a later attempt recorded by a member replaces it with null. The web app shows it in the assignee column of a case no member is assigned to; in a run an app token created (a CI run), a case with neither shows the run’s token (its author) there instead. Neither is an assignee: filtering by assignee, the assignee result lock, “assigned to me”, and PATCH/bulk assign still see the case as unassigned.

POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/open auto-assigns the opener (or the run’s default assignee) only when the project’s autoAssignOnOpen is on and the case’s assignee slot is empty. The slot is not empty, and opening assigns nobody, when:

  • a member is assigned — opening never changes a member assignee;
  • the run was created by an organization app token, a CI reporter (its author.kind is api_token and its author.app is set): no case of a CI run is ever auto-assigned on open, even after a member marks or retries it. A run a personal API token created is not a CI run;
  • in any other run, an API token executed the case’s current attempt (executedByApiToken is set). Once a member records a newer attempt, opening assigns again.

For a case’s current attempt, app tokens and personal API tokens count alike. A case removed from a run and added back keeps its earlier results in the run, so its current attempt is still the latest of them.

When non-null, the object has the same shape as a CustomFieldValueEntry:

FieldTypeDescription
optionUlidstringULID of the selected lifecycle option
optionNamestringDisplay name of the option, resolved for the request locale (see below)
optionColorstring | nullHex color, if any
optionIconstring | nullIcon identifier, if any
optionSystemKeystring | nullSystem key — status_active, status_draft, status_deprecated, or null for custom options

Request locale. optionName resolves according to the request’s Accept-Language header: an organization that has translated its status option names into Spanish sees the Spanish name when it sends Accept-Language: es. An absent or unsupported locale resolves to the English name, so integrators that never send the header see byte-identical responses to before this behavior existed.

Test case content (title, steps, description) is snapshotted when the case is added to a run — edits to the source case do not affect any existing run. caseLifecycleStatus is deliberately not snapshotted: it is resolved live at query time via a join to the source case, so it always reflects the case’s current status in the repository. If you need the snapshotted state, use the priority field (which is part of the snapshot).

The run case records which revision of the case it froze (caseRevision). While the run is open, an edit to the case sets caseUpdated: true on run cases still waiting to be executed (untested, or retested after a retry); the run case keeps the old content until you apply the latest version. A completed run case and a closed run never report an update.

Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/runs/01J...RUN/cases"
{
"cases": [
{
"runCaseUlid": "01J...RC",
"caseUlid": "01J...CASE",
"caseNumber": 7,
"titleSnapshot": "Checkout flow",
"status": "untested",
"caseLifecycleStatus": {
"optionUlid": "01J...OPT",
"optionName": "Draft",
"optionColor": "#F59E0B",
"optionIcon": "circle-dashed",
"optionSystemKey": "status_draft"
},
"customFieldValues": [
{
"fieldUlid": "01J...FLD",
"fieldName": "Priority",
"optionUlid": "01J...PRI",
"optionName": "High",
"optionColor": "#EF4444",
"optionIcon": null,
"optionSystemKey": null
}
]
}
]
}

When the source case has no lifecycle status set, or the case was deleted:

{
"caseLifecycleStatus": null
}

When a customFieldValues entry belongs to a system option (lifecycle status field):

{
"optionSystemKey": "status_active"
}

When it belongs to a custom option (user-defined):

{
"optionSystemKey": null
}
GET /api/v1/runs/{runUlid}/case-keys

Returns each case now in the run with only what a CI reporter needs to run the tests linked to it: its display id and its automation key. A framework reporter uses it to run only the tests of an existing run’s cases.

FieldTypeDescription
caseDisplayIdstringThe case’s display id, for example ACME-14
automationKeystring | nullThe case’s automation key, null when it has none
  • A case removed from the run is not listed, and neither is a case deleted from the project.
  • Items are ordered by case ULID and cursor-paged: limit (default 50, max 200; above that answers 422 validation_failed) and cursor, the nextCursor of the previous page. nextCursor is null on the last page.
  • Closed and aborted runs are readable.
  • It is the one read an app token may make. An app token reads only runs of its organization; any other run answers 404 not_found, and on an organization without a paid plan it answers 403 forbidden, like every app-token call.
  • A run outside the caller’s organization answers 404 not_found. A session or an OAuth token also needs access to the run’s project and is refused 403 forbidden without it; a personal API token or an app token reaches every project of its organization, private projects included.
Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_APP_TOKEN" \
"https://app.probara.net/api/v1/runs/01J...RUN/case-keys?limit=200"
{
"items": [
{ "caseDisplayId": "ACME-14", "automationKey": "cart > adds an item" },
{ "caseDisplayId": "ACME-15", "automationKey": null }
],
"nextCursor": null
}
GET /api/v1/runs/{runUlid}/cases/{runCaseUlid}

Returns the run case with its steps, linkedDefects, and what belongs to its current attempt (the latest result of the case in the run): resultUlid, resultAttachments and, for a result a CI report recorded, what the execution reported:

FieldDescription
parametersThe parameters the current attempt reported, [{ name, value }] in the order sent
linksThe links the current attempt reported, [{ url, name }] in the order sent (name is null when none was sent)
resultStepsThe steps the current attempt reported, flattened to depth-first pre-order: { position, parentPosition, action, status, durationMs, error, expected, data }, where position is the step index from 0 (the stepIndex of a resultAttachments item) and parentPosition its parent’s (null at the top level)

All three are [] when the current attempt reported none — a result marked in the web app, or a case never marked — and follow the current attempt: a later attempt that reported nothing empties them. They are the same lists the attempt snapshot returns for resultUlid. resultSteps is not steps: steps are the case’s own steps. Each item of steps carries keyword, the Gherkin keyword of a step of a Gherkin case, frozen with the step; it is null on a classic step.

PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}

Sets assigneeUlid / status / elapsedMs. A status change marks every step and appends a result row; the response’s resultUlid carries the appended attempt (null for an assignee/elapsed-only patch that changes no status). Requires member+ on the run’s project; viewer receives 403 forbidden.

Allowed on an open run or a closed, non-aborted run when the project’s allowResultsInClosedRuns test-run setting resolves to true (the code default). An organization or project that sets it to false restores the older, stricter behavior: a closed run rejects the mark with 409 conflict. An aborted run always rejects it, under every setting value.

When the project’s assigneeResultLock setting is on and the case has an assignee, only that assignee — or a caller exempt via projects.manage or the project’s current owner — may mark it. Every other caller is refused, including an API token: there is no machine-actor exemption for this lock.

{
"error": {
"code": "run_case_assignee_locked",
"message": "Only the assigned tester may record a result for this case"
}
}

When the project’s timeTracking setting is "required" and the request sets a terminal status with no elapsedMs, the mark is rejected and nothing is persisted:

{
"error": {
"code": "validation_failed",
"message": "elapsedMs is required",
"details": { "code": "elapsed_ms_required", "path": ["elapsedMs"] }
}
}

Under "off" or "optional", a supplied elapsedMs is still accepted and persisted — the setting only governs whether the web client’s time-tracking UI is shown and whether a mark with no elapsedMs is rejected, never whether a supplied value is dropped.

Bulk mark and bulk submit-result are exempt from this rejection — neither POST /runs/{runUlid}/cases/bulk-mark nor POST /runs/{runUlid}/cases/bulk-submit-result carries a per-case elapsedMs field the server could require, so timeTracking: "required" never blocks either bulk endpoint.

See Test-run settings for the full settings vocabulary and resolution chain.

POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest
POST /api/v1/runs/{runUlid}/cases/apply-latest

Brings a run case whose test case was edited after the run froze it to the case’s latest version: its title, suite, description, preconditions, postconditions, priority and steps are replaced, and caseRevision becomes the case’s current revision. The assignee, position and status do not change. Send an empty JSON body ({}). Requires member+ on the run’s project, like marking a case.

A run case can take the latest version only while it is pending (untested, or retested after a retry) and its current attempt has no step results: no step with a status, an actual result or an attachment. To update a completed case, retry it first (POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/retry). Earlier attempts keep the title, fields, steps, step results and attachments they were executed with, so every recorded result stays tied to the steps that were actually run. canApplyLatest tells you in advance.

The single-case endpoint answers the run case with its steps, like GET. It refuses with 409 conflict and changes nothing in the cases below. They are checked in this order, and the first that applies is the one reported:

Situationerror.message / error.details.reason
The run is closedrun is closed
The source case was deletedcase_deleted
The source case has not changed since this run case froze it, in any statuscase_not_outdated
The run case is completed, or its attempt has step resultsrun_case_has_results

case_not_outdated compares caseRevision with caseCurrentRevision; it does not follow caseUpdated, which is false for every completed run case. A completed run case whose source case changed answers run_case_has_results: retry it first.

When the project’s assigneeResultLock is on, the same 403 run_case_assignee_locked rule as marking applies.

The run-wide endpoint applies the latest version to every run case with caseUpdated: true whose current attempt has no step results, in one transaction, and reports the outdated pending cases it left unchanged (completed cases are never candidates):

{
"updated": ["01J...RC1"],
"skipped": [{ "runCaseUlid": "01J...RC2", "reason": "run_case_has_results" }]
}

reason is run_case_has_results, or assignee_locked when the assignee lock keeps the caller from changing that case. A closed run answers 409 conflict (run is closed).

Each updated run case records run.case_version_applied (fromRevision, toRevision); the run-wide call records one run.cases_bulk_version_applied with the updated cases, or nothing when no case was updated. See Audit events API.

PATCH /api/v1/runs/{runUlid}/cases

Reconciles a run’s case selection to a supplied desired full selection, in a single atomic transaction. Unlike POST /runs/{runUlid}/cases (add-only — appends the given case ULIDs and never removes anything), this endpoint treats absence from the desired set as a removal. Both endpoints remain available and unchanged relative to each other.

Requires member+ (the same execute gate as running the tests); viewer receives 403. Allowed on an open run or a closed, non-aborted run (completed, or auto-closed once every case had a result); rejects only an aborted run with 409, mutating nothing.

{
"caseUlids": ["01J...A", "01J...B", "01J...D"],
"caseAssignees": [{ "caseUlid": "01J...D", "assigneeUlid": "01J...USER" }]
}
FieldTypeDescription
caseUlidsstring[]The full desired selection (source case ULIDs). An empty array is valid — it removes every case from the run.
caseAssigneesarray (optional)Per-case assignee for the desired selection. A case ULID absent from this array means that case is (or becomes) unassigned — there is no explicit “clear” value.

Given the current selection and the request above, the server diffs and applies, atomically:

  • Adds each case in caseUlids not currently on the run — snapshotting its title and steps fresh, status: "untested", with the supplied assignee (if any) applied inline.
  • Removes each current case absent from caseUlids — cascading its step snapshots and result attachments, same as the single-case DELETE /runs/{runUlid}/cases/{runCaseUlid} endpoint.
  • Retains each case present in both, unchanged (titleSnapshot, status, assigneeUlid, elapsedMs).
  • Reassigns any retained case whose supplied assignee differs from its current one.

The response is the reconciled run summary (RunResponse), the same shape every other run mutation returns — it does not include the run-cases array; call GET /runs/{runUlid}/cases for the refreshed list.

Each supplied assigneeUlid must resolve to a member of the run’s organization. A cross-tenant or unknown assignee ULID returns 404 not_found — matching the existing create (POST /projects/{projectId}/runs) and per-case patch (PATCH /runs/{runUlid}/cases/{runCaseUlid}) behavior for the same field, not a 422 validation_failed. No case is added, removed, or reassigned when this happens.

Emits exactly one event per case that actually changed: run.case_added for each added case, run.case_removed for each removed case, run.case_assigned for each case whose assignee changed (including a clear, to: null) — see Audit events API for the shared metadata shapes. A case unchanged in both membership and assignee emits nothing. A newly added case’s inline assignee is carried on run.case_added and does not additionally emit run.case_assigned.

Terminal window
curl -sS -X PATCH \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"caseUlids": ["01J...A", "01J...D"]}' \
"https://app.probara.net/api/v1/runs/01J...RUN/cases"

When the project’s review settings have onlyApprovedInRuns on (Pro or Enterprise plan, with review enabled), a run takes in only test cases whose reviewStatus is approved:

  • Cases you choose — caseUlids on POST /api/v1/projects/{projectId}/runs, on POST /runs/{runUlid}/cases and the cases a PATCH /runs/{runUlid}/cases adds — must all be approved. Otherwise the whole request is refused and nothing changes:

    {
    "error": {
    "code": "validation_failed",
    "message": "only approved test cases can be added to runs in this project",
    "details": {
    "reason": "cases_not_approved",
    "notApprovedCaseUlids": ["01J...B"]
    }
    }
    }

    Cases already in the run are kept by a reconcile even when they are no longer approved.

  • Cases a run takes in on its own — a test plan’s selection when caseUlids is omitted, and the cases of the source run of a clone — are filtered: only the approved ones are added, and the answer carries notApprovedSkippedCount, how many were left out. When none is approved the request is refused with 422 validation_failed (details.code empty_selection).

With the option off, review disabled, or a plan without test case review, nothing changes and notApprovedSkippedCount is absent. CI reports (POST /api/v1/projects/{projectId}/reports) are not affected.

Each of the three endpoints below caps the caseUlids array in its request body. These are abuse ceilings, not product ceilings — the server processes any accepted size without a bound-parameter failure. Submitting more than the limit returns 422 validation_failed naming the caseUlids field, and mutates nothing.

EndpointFieldLimit
POST /api/v1/runs/{runUlid}/cases (add cases)caseUlids500
PATCH /api/v1/runs/{runUlid}/cases (reconcile)caseUlids5000
POST /api/v1/projects/{projectId}/runs (create run)caseUlids5000

A parameterized test case has one run case for each combination, and each run case carries its combination in parameters. The run case list returns it on every item ([] for a case without parameters). The run case detail adds siblings, the other run cases of the same case in the run, with their status, assignee, and parameters, and reportedParameters, the parameters the current attempt reported.

POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest refuses with 409 conflict and details.reason: combination_removed when the latest version of the case no longer produces the run case’s combination. The bulk apply, POST /api/v1/runs/{runUlid}/cases/apply-latest, lists the same reason under skipped.

GET /api/v1/runs/{runUlid}/case-keys returns one entry for each run case, with its parameters, so a reporter can tell the run cases of one test case apart.

For the rules behind these fields, see Parameterized test cases and the API reference.