Skip to content

REST API reference

Machine-readable contract for integrators: OpenAPI 3.1 (/openapi/v1.json), generated from Zod schemas in @tcms/shared and kept in sync in CI.

Use the interactive v1 reference for operation-level schemas, request bodies, and response samples.

All integrator routes use the prefix /api/v1.

EnvironmentBase URL
Productionhttps://app.probara.net
Local Workerhttp://localhost:8787

Example:

Terminal window
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/projects"

Unversioned /api/... paths return 404 not_found.

Use an organization-scoped API token as Authorization: Bearer <secret>. With a valid token you do not need X-Organization-Id.

Session-only routes (/api/v1/auth/*, /api/v1/me/api-tokens, invitation accept with cookie) are documented in the authentication guide, not in the OpenAPI document.

Failed requests return JSON:

{
"error": {
"code": "not_found",
"message": "Human-readable message",
"details": {}
}
}

Common code values: validation_failed, not_found, conflict, unauthorized, forbidden, too_many_requests, seat_limit_exceeded, project_limit_exceeded, api_result_limit_exceeded, billing_provider_unavailable, organization_plan_required, run_case_assignee_locked, project_locked.

project_locked is the one of these that is not about the request you sent. An organization holding more projects than its plan allows keeps only its most recently created projects open; every older project is locked, and every read and write against it answers 403 { code: "project_locked" } — over this API and over MCP, for a session and for an API key alike. DELETE /api/v1/projects/{projectId} is the one operation a locked project still serves, because deleting is how the organization brings its total back inside the allowance. Nothing is deleted by the lock itself: GET /api/v1/projects keeps listing the project, with a non-null lockedAt, and raising the organization’s allowance clears the stamp and reopens it unchanged. Treat it as retryable only after a human acts — no amount of retrying will clear it.

A public run share link belonging to a locked project answers the same byte-identical 404 { code: "not_found" } an unknown token earns, deliberately: the holder of a valid link must not be able to use it to learn that the organization is over its allowance.

details occasionally carries its own code, distinct from the top-level one — for example a validation_failed mark rejection carries details: { code: "elapsed_ms_required", path: ["elapsedMs"] }. Always branch on the top-level code first; treat details.code as supplementary, never a replacement.

Table list endpoints (projects, milestones, runs, defects, organization members, invitations, user groups, custom fields, and the archived test-case listing below) use page-based pagination. They accept page (1-based, default 1) and pageSize (default 50, max 200). Out-of-range or non-integer values return 422 validation_failed. Responses carry the page, its size, and the total count of all rows matching the active filters and search — not just the rows on the page:

{
"items": [],
"page": 1,
"pageSize": 50,
"total": 0
}

A page beyond the last available page returns 200 with items: [] and the real total. Many table endpoints also accept a q query param for case-insensitive substring search; q is applied before pagination and reflected in total.

Cursor pagination (feeds and trees). Chronological feed endpoints (audit events and their run/defect-scoped variants) and the suite/test-case tree endpoints (including the default, active-only GET /api/v1/projects/{projectId}/test-cases and its archived=true filter) keep cursor pagination instead: limit (default 50, max 200) and optional cursor (ULID), responding with { "items": [], "nextCursor": "01ARZ3…" }. When nextCursor is null, there are no more pages. The one exception is the page-based GET /api/v1/projects/{projectId}/test-cases/archived endpoint below, which exists specifically so archived cases can be numbered-page-navigated (Trash) instead of walked as a cursor feed.

Breaking API changes require a new major path (for example /api/v2). /api/v1 remains until explicitly deprecated.

Suites and test cases expose stable display IDs in API responses (displayId, plus suiteNumber / caseNumber). Integrators can resolve resources by display ID without knowing the ULID:

  • GET /api/v1/projects/{projectId}/suites/by-display-id/{displayId} — {projectId} is the project code (for example ACME). {displayId} must match {projectId}-S{positiveInteger} (for example ACME-S2). Returns the same SuiteResponse shape as GET /api/v1/suites/{suiteUlid}.
  • GET /api/v1/projects/{projectId}/test-cases/by-display-id/{displayId} — {displayId} must match {projectId}-{positiveInteger} (for example ACME-14). Returns the same TestCaseResponse shape as GET /api/v1/test-cases/{caseUlid}.

Malformed display IDs return 422 validation_failed. Unknown IDs return 404 not_found. Prefix mismatches (wrong project code in the suffix) return 400 validation_failed.

Suite mutations. Creating (POST /api/v1/projects/{projectId}/suites), renaming or moving (PATCH /api/v1/suites/{suiteUlid}), and deleting (DELETE /api/v1/suites/{suiteUlid}) suites require the suite execute permission (held by owner/admin/member); a viewer receives 403 forbidden. Reading suites (tree, detail, display-ID lookup) is unchanged for any authenticated member.

GET /api/v1/projects/{projectId}/test-cases/archived returns a page-based, project-wide listing of archived test cases — most-recently-archived first (archivedAt descending, ULID descending as a tie-break). It accepts the standard page / pageSize params and responds with the standard page shape:

{
"items": [],
"page": 1,
"pageSize": 50,
"total": 0
}

total counts every archived case in the project, not just the returned page; a page past the last one still returns 200 with items: [] and the real total. The listing ignores suite scoping — it always returns archived cases from every suite (and unassigned cases), matching the archived-case exclusion already applied by the default GET /api/v1/projects/{projectId}/test-cases listing.

This endpoint is additive: it does not replace GET /api/v1/projects/{projectId}/test-cases?archived=true, which keeps its existing { items, nextCursor } cursor contract and behavior unchanged. Use the cursor endpoint to walk the full archived set as a feed; use /archived to render a numbered-page Trash view.

Related pages

Test cases no longer carry classification attributes (priority, severity, status, type, layer, behavior, automationStatus, isFlaky, preconditions, postconditions) as top-level body keys. Those attributes are now system custom fields and are read/written via the dedicated custom-field-values contract:

  • GET /api/v1/test-cases/{caseUlid} includes a customFieldValues array with one entry per visible field for the case’s project. Missing stored values materialize to the field’s defaultValue (or null).

  • GET /api/v1/projects/{projectId}/test-cases also embeds customFieldValues on each item, following the same shape. The list endpoint assembles the array via a bulk read so the number of D1 round-trips remains constant regardless of page size.

  • Each entry carries two optional, nullable keys alongside fieldUlid and value:

    • systemKey: the field definition’s systemKey ('priority', 'severity', 'status', 'type', 'layer', 'behavior', 'automation_status', 'is_flaky', 'preconditions', 'postconditions') for group = 'system' fields, or null for group = 'custom'.
    • optionName: the selected option’s name ('High', 'Critical', 'Active', …) for select_single / radio values when the value is a valid option ULID; null for other types or when the value is null. optionName is resolved for the request locale the same way as field definitions are now bilingual below: Accept-Language: es returns the Spanish name when one is authored, falling back to English otherwise — an absent or unsupported locale header resolves to English, so a client that never sends the header sees byte-identical responses to before this change.

    Clients can render system-field chips by reading these keys directly — no separate definitions fetch is required. Older clients that ignore the new keys keep working unchanged.

Field titles, placeholders, and option names may now carry a Spanish translation. Title and option name each require a non-empty English value; a placeholder’s English value is optional (a field may have no placeholder at all in either language), so placeholderI18n.en may be null. GET /api/v1/orgs/{orgUlid}/custom-fields resolves title, placeholder, and each option’s name for the request locale (Accept-Language: es returns the Spanish value when one is authored, falling back to English otherwise — an absent or unsupported locale header resolves to English, so a client that never sends the header sees byte-identical responses to before this change). Pass ?locales=all to additionally receive titleI18n, placeholderI18n, and each option’s nameI18n — a map keyed by locale tag ({ en, es }) — alongside the resolved values. The q search parameter matches a field’s title stored in either locale.

POST /api/v1/orgs/{orgUlid}/custom-fields, PATCH .../custom-fields/{fieldUlid}, and POST .../custom-fields/{fieldUlid}/reset accept the same ?locales=all parameter on their single-object field response, gated identically: without it, the response stays byte-identical to the pre-localization shape (no *I18n keys at all), including for API-key-authenticated integrators.

Export (GET /api/v1/projects/{projectId}/exports) is the one exception to locale resolution: option names in an exported file are deliberately not locale-resolved and always stay English-canonical, so a CSV/JSON export and a re-import always agree on one vocabulary, whoever downloaded the file. This is unrelated to the customFieldValues.optionName documented above, which is locale-resolved on every ordinary read.

PATCH /api/v1/test-cases/{caseUlid} accepts a consolidated body that bundles scalar field changes, the step list, and custom field values into one request. This is what the web editor uses on save; each call results in one audit event with a unified diff.

{
"patch": {
"title": "Login fails on mobile",
"description": "...",
"suiteUlid": "01J...SUITE",
"milestoneUlid": null,
"tags": ["smoke"]
},
"steps": [
{ "position": 1, "action": "Open the login screen" },
{ "position": 2, "action": "Submit invalid credentials" }
],
"customFieldValues": [{ "fieldUlid": "01J...PRIORITY", "value": "01J...OPTION_HIGH" }]
}

At least one of patch, steps, or customFieldValues MUST be present. The handler reads the existing case row, steps, and visible custom field values, applies the requested mutations, computes a unified diff, and emits exactly one test_case.updated (or test_case.moved when suiteUlid changed) audit event. If any sub-mutation fails, the response is non-2xx and no audit event is recorded.

Requires the test case write permission (held by owner/admin/member); a viewer token or session receives 403 forbidden and no field is mutated.

The standalone PUT /api/v1/test-cases/{caseUlid}/steps and PUT /api/v1/test-cases/{caseUlid}/custom-field-values endpoints remain available for direct API integrators, but they no longer emit audit events on their own — the consolidated PATCH is the only source of test_case.updated events. PUT .../steps requires the same test case write permission (held by owner/admin/member); a viewer receives 403 forbidden.

Steps support optional attachments[] on create (POST .../test-cases) and on PUT .../steps. Images use a stage-and-commit flow, and staging or deleting a step attachment requires the attachment execute permission (held by owner/admin/member); a viewer receives 403 forbidden:

  • POST /api/v1/test-cases/{caseUlid}/step-attachments:stage — multipart upload (multiple file parts); returns staged object keys under staging/<orgUlid>/ with no DB row.
  • Commit by including those refs on the step’s attachments array when saving steps.
  • DELETE /api/v1/test-cases/{caseUlid}/step-attachments/{attachmentUlid} — remove one committed attachment.

See Step attachment images for limits, reconciliation rules, and examples.

  • PUT /api/v1/test-cases/{caseUlid}/custom-field-values atomically replaces the case’s value set:
Terminal window
curl -sS -X PUT \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "content-type: application/json" \
-d '{
"values": [
{ "fieldUlid": "01J...PRIORITY", "value": "01J...OPTION_HIGH" },
{ "fieldUlid": "01J...IS_FLAKY", "value": true },
{ "fieldUlid": "01J...PRECONDITIONS", "value": "Have a user logged in" }
]
}' \
"https://app.probara.net/api/v1/test-cases/01J.../custom-field-values"

For checkbox fields, the JSON boolean form (true / false) matches what GET returns; the legacy string form ("true" / "false") is still accepted.

Empty / null values delete the corresponding row. The endpoint returns 200 with the full embedded array. Validation rules per field type live in the OpenAPI schema (PutCustomFieldValuesInput).

To discover the system field ULIDs (priority, severity, etc.) for an organization, call GET /api/v1/orgs/{orgUlid}/custom-fields. Add ?projectUlid={projectUlid} to filter to definitions visible for a specific project (useful when building test-case forms).

PATCH /api/v1/test-cases/{caseUlid}/custom-field-values/{fieldUlid} upserts or clears one field without replacing the rest of the case’s values. The body is { "value": <unknown> } only (no extra keys). The response is 200 with the same full customFieldValues embedded array as PUT.

PATCH only enforces required on the targeted field; other required fields’ state is not inspected. Use PUT for atomic full-form replacement when every visible required field must be present in the body.

Terminal window
curl -sS -X PATCH \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "content-type: application/json" \
-d '{ "value": "01J...OPTION_HIGH" }' \
"https://app.probara.net/api/v1/test-cases/01J.../custom-field-values/01J...SEVERITY"

Empty / null values delete the row when the field is not required. Clearing a required field returns 422 validation_failed with details referencing path: ["value"].

PATCH /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid} updates definition metadata and, for option-based types, the options list. Each entry in options MAY include the existing option’s ulid. When ulid matches a row already stored for the field, PATCH preserves that ULID and updates name, icon, color, and position in place. Entries without ulid are inserted as new options with a freshly generated ULID. Rows whose ulid is absent from the patch are deleted (system fields still reject removing seed options that carry a systemKey).

Because select_single, select_multi, and radio values store option ULIDs in custom_field_values, always echo back the ulid returned by GET when you mean to update an existing option. Omitting ulid on a row that already exists creates a new option and leaves any stored values pointing at the old ULID orphaned.

Required test-run fields require a default

Section titled “Required test-run fields require a default”

entity on POST /api/v1/orgs/{orgUlid}/custom-fields and PATCH /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid} accepts test_case, test_run, and defect. A definition with entity: "test_run" and isRequired: true MUST carry a non-empty defaultValue. Violating this on either endpoint returns 422 validation_failed with details.field = "defaultValue", and creates or modifies no definition row. The invariant is evaluated against the definition’s effective state after the write, so marking an existing test_run field required with no stored default is rejected on PATCH exactly as it is on POST; setting isRequired and a non-empty defaultValue together in the same request is accepted. test_case and defect fields are exempt and may stay required with no default, as before.

Owners and admins may restore a system custom field definition to its seeded state:

Terminal window
curl -sS -X POST \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "X-Organization-Id: $ORG_ULID" \
-H "content-type: application/json" \
-d '{}' \
"https://app.probara.net/api/v1/orgs/$ORG_ULID/custom-fields/$FIELD_ULID/reset"
  • 200 — Returns { field } with seed title, cleared placeholder, seed option names/icons/colors, and stable option ULIDs (matched by systemKey). User-added options (systemKey: null) are deleted.
  • 400 not_system_field — The target is a custom (non-system) field.
  • 403 — Caller is not an owner/admin.
  • 404 — Unknown field, unknown org, or cross-tenant access.

The operation is idempotent on an already-pristine field. Project scope (allProjects, projectUlids) is never changed.

Value cascade: When user-added options are removed, stored test-case values that referenced those option ULIDs are updated in the same request: select_single / radio values fall back to the seed default ULID when configured, otherwise the value row is deleted; select_multi arrays drop removed ULIDs and apply the seed default array when the result would be empty. Non-option field values (paragraph, checkbox, etc.) are not modified.

PATCH /api/v1/projects/{projectId} requires the project management permission (held by owner/admin). member and viewer receive 403 forbidden with error.code forbidden. Creating a project (POST /api/v1/projects) requires the same project management permission: only owner/admin may create a project — a member or viewer now also receives 403 forbidden. GET on /api/v1/projects and GET /api/v1/projects/{projectId} remain unchanged for authorized org members.

GET /api/v1/projects/{projectId} includes avatarKey (string | null) and avatarVersion (number). When avatarKey is non-null, the image is served publicly at GET /__assets/{avatarKey}?v={avatarVersion} (no session).

MethodPathBodyResponse
POST/api/v1/projects/{projectId}/avatarmultipart/form-data with one file (PNG/JPEG/WebP, max 5 MiB){ avatarKey, avatarVersion }
DELETE/api/v1/projects/{projectId}/avatar—{ avatarKey: null, avatarVersion }

POST/DELETE on /api/v1/projects/{projectId}/avatar require owner or admin. Images are normalized server-side to 256×256 WebP under avatars/projects/{projectUlid}.webp.

GET/PATCH /api/v1/projects/{projectId}/access read and change a project’s public/private access mode and owner — a dedicated resource; access state is never added to the general GET /projects/{projectId} response. PATCH requires organization owner/admin or the project’s current owner — a member with only read access (a granted allowlist row) is not automatically authorized to toggle it.

GET/POST/DELETE /api/v1/projects/{projectId}/access/members list, grant, and revoke individual members on a private project’s allowlist, without ever changing the project’s mode — only PATCH /access { mode } does that. Same authorization rule as the toggle. Removing the last remaining grant now succeeds (204): the allowlist may legitimately be empty on a private project; publishing a project is always an explicit mode change through PATCH /access { mode }, never a side effect of revoking the last person on the allowlist.

See Project access for the full request/response shapes, the toggle-seeding rules, and one documented limitation: API tokens are never evaluated against the allowlist. Enforcement itself follows the data, not the URL shape — routes that resolve their project transitively from an entity ULID are gated identically to /projects/{projectId}/*.

A user group can also be assigned to a project’s allowlist, granting every current and future group member access in one operation:

MethodPathNotes
GET/api/v1/projects/{projectId}/access/groupsList the groups assigned to the project’s allowlist
POST/api/v1/projects/{projectId}/access/groupsAssign a group ({ userGroupUlid }); succeeds on a public project — the assignment is dormant until private
DELETE/api/v1/projects/{projectId}/access/groups/{userGroupUlid}Unassign a group; never 409, even for the last remaining group
GET/api/v1/groups/{userGroupUlid}/projectsThe reverse view: the projects a given group is assigned to (see below)
POST/api/v1/groups/{userGroupUlid}/projectsAssign the group to one or many projects in one request ({ projectUlids: string[] }); no org gate of its own — authorized per project

Same authorization rule as the member allowlist (organization owner/admin, or the project’s current owner). Unlike the individual family, assigning a group is allowed on a public project: access_mode is a stored fact, never inferred from an assignment, so the grant stays dormant and survives every later mode toggle in either direction. There is no group-level revoke — the individual allowlist’s own revoked row always outranks a group grant.

GET /api/v1/groups/{userGroupUlid}/projects is read-open like the other group GETs (see User groups), so a plain organization member can call it — but unlike the group’s own projectCount, this listing filters out any project the caller cannot otherwise discover: a private project the group is assigned to is silently omitted from items and total for that caller, while an organization owner/admin sees every assignment.

GET/PATCH /api/v1/projects/{projectId}/run-settings read and change the twelve settings that govern marking, closed-run writes, and run-creation defaults — resolved through project → organization → code default for eleven of the twelve keys. GET/PATCH /api/v1/orgs/{orgUlid}/run-settings read and change the organization-wide defaults for those same eleven keys. PATCH on the project endpoint requires organization owner/admin or the project’s current owner; PATCH on the organization endpoint requires the org-settings.manage permission. See Test-run settings for the full vocabulary, the resolution/source contract, and the one documented client-only gap (requireCommentOnNegativeResult).

GET /api/v1/projects accepts two optional, additive query parameters. Both are opt-in: a request without them returns exactly today’s flat item shape, and the POST/GET /api/v1/projects/{projectId} response shapes are unchanged.

Query paramNotes
include=statsEmbeds an optional stats object, a team array, and a teamCount integer on each list item. The only accepted value is stats.
memberUlidComma-separated set of member ULIDs. Filters the page (and total) to projects whose team includes any of the listed members. Unknown ULIDs (not active members of your org) are dropped silently. Independent of include — you may filter without requesting stats.

When include=stats is present, each item additionally carries:

FieldTypeMeaning
stats.casesintegerNon-archived test cases
stats.runsintegerAll test runs
stats.runsInProgressintegerTest runs with state = open
stats.defectsUnresolvedintegerDefects with status open or in_progress
stats.milestonesintegerLive (non-deleted) milestones
teamarrayLean, avatar-ready subset of the project’s team — at most 4 members, ordered by membership join time ascending. Each entry is { userUlid, firstName, lastName, avatarKey, avatarVersion }.
teamCountintegerFull team size for the project (drives a +N overflow indicator).

A project with no matching rows reports 0 for each metric (never a missing field). All counts are tenant-isolated — they only ever include your own organization’s rows.

Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/projects?include=stats&memberUlid=01J...,01K..."

team/teamCount/memberUlid are mode-aware. For a public project the team is the active organization membership (minus anyone individually revoked) — the original opt-out behavior, unchanged. For a private project the team is exactly the members holding a granted allowlist row, intersected with active organization membership; members who reach the project only through the organization owner/admin role bypass hold no grant and are correctly absent from the team, even though they do appear in the project’s access member list. memberUlid narrows against this same mode-aware set, so it has real effect on both public and private projects.

GET /api/v1/projects also accepts four optional, additive presence/state filters that narrow which projects appear. They are applied server-side, so the returned total and pagination always reflect the filtered set. Each filter takes a comma-separated list of option tokens (you may also repeat the parameter, e.g. runs=without&runs=active). Within one filter the selected options are combined with OR; across different filters they combine with AND. They stack with q and memberUlid, and a request without any of them is unchanged. Unknown tokens are dropped silently (no 422); a filter whose tokens are all unknown is treated as absent.

Each filter mirrors the corresponding metric definition exactly, so filtering agrees with the counts you see under include=stats.

Query paramOptionsMeaning
runswithoutProjects with no test runs
activeProjects with at least one open run (state = open)
anyProjects with at least one run (any state)
defectshasProjects with at least one unresolved defect (status open or in_progress)
withoutProjects with no unresolved defect (resolved-only or none)
milestoneshasProjects with at least one live (non-deleted) milestone
withoutProjects with no live milestone
caseshasProjects with at least one active (non-archived) test case
withoutProjects with no active case

defects means unresolved. The defects filter only ever considers defects whose status is open or in_progress — the same definition as stats.defectsUnresolved. Resolved or closed defects never make a project match defects=has.

All four filters are tenant-isolated: they only ever consider rows in your own organization.

Terminal window
# Projects that have an open run AND no unresolved defects, named like "mobile":
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/projects?q=mobile&runs=active&defects=without"
# OR within a filter: projects with no runs OR an active run:
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/projects?runs=without,active"

A run snapshots the cases selected into it. Create a run with the cases to execute, then read and mutate its cases:

MethodPathNotes
POST/api/v1/projects/{projectUlid}/runsBody { name, description?, defaultAssigneeUlid?, environmentId?, environment?, caseUlids?[], planUlid?, configurationUlids?[], customFieldValues?[] }. Snapshots title + steps of each case. caseUlids may be omitted only when planUlid is supplied (the run’s cases and per-case assignees seed from the plan’s current selection); otherwise it is required. One request creates exactly one run — a planUlid never fans out into more than one.
GET/api/v1/projects/{projectUlid}/runsEach item carries progress (total, executed, passRate), an exact 5-band counts object, timing aggregates (firstResultAt, lastResultAt, totalDurationMs), and a discriminated author (see below).
GET/api/v1/projects/{projectUlid}/runs/environmentsDeprecated. Returns { items: string[] } with distinct non-empty free-text environment values for the project, sorted ascending. Retained for back-compat; prefer filtering by environmentId ULID instead.
GET / PATCH/api/v1/runs/{runUlid}PATCH updates metadata only (name, description, defaultAssigneeUlid, environmentId, milestoneId, configurationUlids); the aggregated status is derived, never patched. Allowed on a closed, non-aborted run; returns 409 conflict only when the run is aborted.
POST/api/v1/runs/{runUlid}/closeCompletes the run early (state = closed) while untested cases remain. A run also closes itself automatically once every case has a result — no client call needed for that path. Calling close on an already-closed run returns 409 conflict (the latch is one-way).
POST/api/v1/projects/{projectUlid}/runs/{runUlid}/clone”Run again”: creates a new open run from a closed source run (completed or aborted). Body { title: string, cloneAssignees?: boolean, statusFilter?: TestOutcomeStatus[] } — title is required; statusFilter selects which source cases carry over (omitted/empty = every case). Generalizes the former rerun-failed path, which is removed.
GET / POST/api/v1/runs/{runUlid}/casesList run cases; POST adds (and snapshots) more cases.
POST/api/v1/runs/{runUlid}/cases/bulk-markBody { caseUlids: string[1..500], status } (passed | failed | skipped | blocked). One atomic transaction; one run.cases_bulk_marked audit event; returns { affected, unchanged }.
POST/api/v1/runs/{runUlid}/cases/bulk-assignBody { caseUlids, assigneeUlid: string | null }. Same atomicity and response shape; emits run.cases_bulk_assigned.
POST/api/v1/runs/{runUlid}/cases/bulk-retryBody { caseUlids }. Resets selected cases (and their steps) to untested; emits run.cases_bulk_retried.
POST/api/v1/runs/{runUlid}/cases/bulk-removeBody { caseUlids }. Removes cases from the run with pre-deletion snapshots; emits run.cases_bulk_removed.
GET / PATCH/api/v1/runs/{runUlid}/cases/{runCaseUlid}PATCH sets assigneeUlid / status / elapsedMs. A status change marks every step and appends a result row. The PATCH response includes nullable resultUlid (the appended attempt when status changed; null for assignee/elapsed-only patches). GET detail adds linkedDefects[] — distinct defects linked via any attempt on the case (ulid, defectNumber, title, status, and assigneeUlid, which is null when the defect is unassigned). PATCH rejects with 403 run_case_assignee_locked when the project’s assigneeResultLock setting is on and the caller is neither the assignee nor exempt (no API-token exemption), and with 422 validation_failed (details.code: "elapsed_ms_required") when timeTracking is required and a terminal mark omits elapsedMs — see Run cases.
POST/api/v1/runs/{runUlid}/cases/{runCaseUlid}/openAuto-assigns the acting user when unassigned.
PATCH/api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}Marks one step; the case status is the aggregate of its steps.
GET/api/v1/runs/{runUlid}/resultsAppend-only marking history; a case may appear multiple times, ordered by executedAt.

All mutating endpoints require role member or above (viewer → 403 forbidden) and emit audit events. An aborted run always rejects these mutations with 409 conflict. A closed, non-aborted run accepts them by default (allowResultsInClosedRuns defaults true); a project or organization that sets that test-run setting to false restores the older behavior, rejecting them on a closed run too.

Runs can be linked to a structured Environment entity managed by the project.

Create (POST /api/v1/projects/{projectUlid}/runs)

FieldNotes
environmentIdOptional ULID of an environment that belongs to the run’s project. Returns 404 not_found if the ULID does not resolve to a live environment in the project. Omit or pass null to leave unlinked.
environmentBack-compat only. Legacy free-text string. When environmentId is absent and environment is a non-empty string, the server finds or creates an environment whose slug matches the slugified value and links the run to it. When both are provided, environmentId takes precedence.

Update (PATCH /api/v1/runs/{runUlid})

FieldNotes
environmentIdOptional ULID to change or link the run’s environment. Pass null to unlink. Returns 404 not_found for unknown ULIDs. Free-text environment is not accepted on PATCH.

Response fields added to RunResponse

FieldTypeNotes
environmentIdstring (ULID) | nullULID of the linked environment, or null when not linked.
environment{ ulid, name, slug } | nullSummary of the linked environment object, or null when not linked.
environmentNamestring | nullDeprecated. The raw free-text value of the legacy environment TEXT column. Use environment.name instead.

GET /api/v1/projects/{projectId}/runs accepts page-based pagination (page, pageSize) plus these server-side filters. Filters compose with AND semantics and total reflects the filtered population:

ParameterNotes
qCase-insensitive substring search over run name and environment
statusComma-separated projected status: in_progress, passed, failed
envRepeatable value. Repeat env for OR semantics. Pass the environment ULID to filter by linked environment entity; pass a free-text string to match the legacy environment text column. ULID and free-text values compose with OR when mixed. Values may contain commas, so do not comma-split.
authorUlidUser ULID of the run author. API-token authored runs do not match user authors.
assigneeUlidDefault-assignee user ULID, or empty for runs without a default assignee.
cfRepeatable custom-field filter, <fieldUlid>:<value[,value…]>. Values within one cf entry OR-compose; repeat cf for a different field to AND-compose across fields. Option-based fields take option ULIDs, checkbox takes true/false, user_picker takes a user ULID, and the literal token empty matches runs with no stored row for that field. Matching considers stored rows only — a materialized default never matches.

Example:

GET /api/v1/projects/DEMO/runs?status=failed&env=01JXXXXXXXXXXXXXXXXXXXXXXXXX&page=1&pageSize=50

Every RunResponse (returned by GET /runs, GET /runs/{runUlid}, and as the body of POST /runs) carries the following fields in addition to the run’s identity, metadata, and state/status axes:

  • total, executed, passRate — legacy progress counters (kept for back-compat).
  • counts — exact 5-band breakdown of test_run_cases.status: { passed, failed, blocked, skipped, untested }. Σ counts = total. Use this to render result bars; do not synthesize from passRate.
  • firstResultAt, lastResultAt — epoch ms of the first and last test_results.executed_at registered on the run; both null until the first result lands. Use lastResultAt − firstResultAt for the wall-clock elapsed of the run; for an in-progress run use now − firstResultAt.
  • totalDurationMs — sum of test_results.duration_ms for the run (results without a measured duration contribute 0).
  • author — discriminated snapshot of the actor that created the run, taken at creation time so the run remains renderable after the user or token is deleted. One of:
    • { "kind": "user", "ulid": "<userUlid>", "displayName": "...", "avatarKey": <string|null> }
    • { "kind": "api_token", "ulid": "<apiTokenUlid>", "name": "..." }
    • null — legacy rows the migration could not recover; render as an em-dash.
  • configurations — array of the run’s configuration tags, each { ulid, configurationUlid, groupName, valueName }. configurationUlid is null once the underlying configuration value has been hard-deleted; groupName/valueName are snapshots taken when the tag was set, so the combination stays renderable after a rename or deletion. [] for a run with no configurations. Present on every RunResponse — the runs list, the run detail, and each plan’s run items — never omitted.

Runs created from the integrator API (Authorization: Bearer <apiTokenSecret>) automatically carry author.kind = "api_token"; runs created from a logged-in session carry author.kind = "user". The same shape appears on GET /api/v1/runs/{runUlid}.

custom_fields definitions accept entity: "test_run" alongside test_case and defect. Run-scoped values are read and written via a dedicated contract, matching the test-case and defect conventions:

  • GET /api/v1/runs/{runUlid} embeds a customFieldValues array with one entry per visible test_run field for the run’s project. Missing stored values materialize to the field’s defaultValue (or null). The runs list (GET /api/v1/projects/{projectId}/runs) does not embed this array — reading it always requires the detail endpoint, so paging through runs never pays a per-row cost.
  • PUT /api/v1/runs/{runUlid}/custom-field-values atomically replaces the run’s value set:
Terminal window
curl -sS -X PUT \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "content-type: application/json" \
-d '{
"values": [
{ "fieldUlid": "01J...BUILD", "value": "4471" },
{ "fieldUlid": "01J...REGRESSION", "value": "01J...OPTION_YES" }
]
}' \
"https://app.probara.net/api/v1/runs/01J.../custom-field-values"

A visible field omitted from the body has its stored row deleted; a stored row for a field that is no longer visible for the run’s project is left untouched. Empty / null values delete the corresponding row. The endpoint returns 200 with the full embedded array — the same shape GET /api/v1/runs/{runUlid} returns.

Requires the result execute permission (held by owner/admin/member); a viewer token or session receives 403 forbidden and no row is touched. Only an aborted run rejects the write with 409 conflict; a closed, non-aborted run still accepts it — the same freeze rule every other run mutation follows.

A successful write that changes at least one value folds into the run’s existing run.updated audit event (metadata.customFieldChanges); it never emits a standalone custom_field event. Resubmitting a value set identical to what is already stored returns 200 with no new audit event.

There is no single-field PATCH .../custom-field-values/{fieldUlid} route for runs — use PUT for every write.

To discover the visible test_run field ULIDs for a project, call GET /api/v1/orgs/{orgUlid}/custom-fields?entity=test_run&projectUlid={projectUlid}.

Create-time values (POST /api/v1/projects/{projectUlid}/runs)

The create body accepts an optional customFieldValues array in the same { fieldUlid, value } shape as PUT:

Terminal window
curl -sS -X POST \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "content-type: application/json" \
-d '{
"name": "Sprint 24",
"caseUlids": ["01J...CASE"],
"customFieldValues": [{ "fieldUlid": "01J...BUILD", "value": "4471" }]
}' \
"https://app.probara.net/api/v1/projects/DEMO/runs"

A required test_run field always resolves to a value at create time: pass it explicitly, or omit it entirely and the field’s defaultValue is persisted for you (a required field can never be defined without a non-empty default, so create can never fail for a missing required value). An optional field omitted from the body stores no row at all — its default is only materialized when read, exactly like a run that never called PUT. An invalid value for any submitted field (wrong type, unknown option, etc.) rejects the entire create with 422 validation_failed — no run is created and no value row is written. The values are folded into the run’s single run.created audit event (metadata.customFieldChanges); a run created with no run fields emits the same metadata shape as before this field existed.

Cloning a run (POST .../runs/{runUlid}/clone) copies the source run’s stored values into the new run, translated against the target project’s field definitions — a stored value for a field no longer visible in the target project is silently dropped, never rejected. The copy folds into the clone’s run.cloned event; the source run is never modified and never re-audited.

GET /api/v1/orgs/{orgUlid}/api-result-usage reports the organization’s authoritative counted results for the current calendar month plus its effective apiResultsPerMonth ceiling — the same figures the monthly API result limit above enforces, never a recomputed count. Any authenticated member of the organization may call it; there is no role gate.

{ "periodKey": "2026-08", "used": 42, "limit": 5000 }

periodKey is an opaque YYYY-MM (UTC) tag — treat it as an identifier, not something to parse or compute against. Reading usage never creates, resets, or advances the counter, so polling it has no side effect on your ceiling. Once the calendar month turns, the readout reports 0 for the new period automatically, with no operator action or deploy — mirroring the same rollover the write-side limit already performs lazily on the next counted result.

GET /api/v1/orgs/{orgUlid}/seat-usage reports the organization’s used seats and its effective seat limit — the same used-seat definition the seat-limit enforcement above compares, never a recomputed or roster-derived count. Any authenticated member of the organization may call it; there is no role gate.

{ "used": 2, "limit": 3 }

A staff-suspended member still occupies a seat here even though the visible member roster hides that member — the readout always agrees with the guard, never with the roster. A pending, unexpired invitation reserves a seat the same way it does for enforcement; an expired-but-still-pending invitation reserves none. Reading seats never mutates anything: it does not expire a stale invitation, revoke anything, or refuse on seat grounds by itself. An unlimited effective limit is returned exactly as the entitlements reader resolved it.

GET /api/v1/orgs/{orgUlid}/audit-events/export downloads the organization’s audit log as a single CSV artifact — one row per audit event, ordered chronologically — for a compliance or security reviewer who needs “the last quarter of workspace activity, as a file.” It requires organization membership with role owner or admin, and the auditExportEnabled entitlement (a paid-tier capability). Both refusals return 403 { code: "forbidden" } and are indistinguishable on the wire — a caller cannot learn whether the organization holds the entitlement by testing their own role, or vice versa.

Both from and to query parameters are required, ISO-8601 UTC with the Z designator (a bare date or an offset-bearing literal is rejected). The window is half-open — from is inclusive, to is exclusive — so two consecutive calls with to of the first equal to from of the second never produce a duplicate or a gap at the boundary instant. The span may not exceed 366 days; a missing parameter, an inverted window (to <= from), or an oversized span all return 422 { code: "validation_failed" }.

An exact count of matching events is taken before any row is read. A window whose count exceeds 25,000 events is refused 422 { code: "validation_failed" } with a message naming both the actual count and the ceiling — narrow the window and retry. The export is rate-limited to 10 calls per organization per hour, on its own bucket independent of the test-case export limit above; exceeding it returns 429 { code: "too_many_requests" }.

The response is Content-Type: text/csv; charset=utf-8 with Content-Disposition: attachment naming a filename that carries the requested window, so two exports never collide in a downloads folder. Columns: created_at, event_ulid, action, actor_kind, actor_ulid, actor_label, entity_type, entity_ulid, entity_label, run_ulid, metadata. A cell whose source value could be interpreted as a spreadsheet formula (starting = + - @) is neutralized with a leading ' before quoting. The run_ulid column is empty for an event that was never associated with a test run, carries the run’s ULID when the run still exists, and carries the literal [run purged] or [run unknown] sentinel for the two cases where the run no longer exists but the event was (or may have been) run-scoped. An actor whose identity has since been erased renders [identity removed] in actor_label rather than any resurrected personal detail. Exporting is a pure read: it never mutates or deletes audit data, and calling it does not itself generate a new audit event.

  • A scheduled subscription change can now be cancelled through the API. DELETE /api/v1/orgs/{orgUlid}/subscription/pending-change discards a change scheduled to take effect at the period end (a seat reduction, or a plan move deferred to the renewal) and leaves the current commitment in force, returning the same subscription readout every other mutation on this surface returns. When nothing is scheduled it answers 409 { code: "conflict" } without sending any change to the billing provider. Requires org-settings.manage.

  • Seat commitments below current usage are now accepted, and the dedicated refusal is retired. Breaking — the 409 { code: "seat_commitment_below_usage" } refusal is removed from POST /api/v1/orgs/{orgUlid}/subscription/checkout and PATCH /api/v1/orgs/{orgUlid}/subscription/seats. Any positive seat commitment is now accepted; when the committed seats end up below the organization’s used seats, Probara demotes the newest non-owner members (pending invitations first) to the free viewer role instead of refusing the change.

  • Test-run settings are now readable and configurable, at project and organization level. GET/PATCH /api/v1/projects/{projectId}/run-settings and GET/PATCH /api/v1/orgs/{orgUlid}/run-settings expose twelve settings governing marking, closed-run writes, and run-creation defaults. Eleven resolve through project → organization → code default; defaultAssigneeUserId is project-only and has no organization tier. Three related behavior changes ship alongside: (1) a closed, non-aborted run now accepts result-notes edits and result-attachment stage/commit/delete by default (allowResultsInClosedRuns defaults true) — an organization that wants the previous, stricter behavior sets it to false; (2) marking a run case now rejects with 403 { code: "run_case_assignee_locked" } when the project’s assigneeResultLock setting is on and the caller is neither the assignee nor exempt, with no exemption for API tokens; (3) marking a run case with a terminal status now rejects with 422 validation_failed (details.code: "elapsed_ms_required") when the project’s timeTracking setting is required and the request omits elapsedMs — bulk mark and bulk submit-result are exempt from this last rejection. See Test-run settings and Run cases.

  • Invitations can now be created in bulk, up to 50 addresses per request. POST /api/v1/orgs/{orgUlid}/invitations/bulk accepts { emails: string[], role, locale?, grantAllPrivateProjects? } — one role and one locale apply to the WHOLE list, never per address. emails accepts at most 50 entries; a duplicate address in the same list is evaluated once, in first-appearance order. The response is always { results: [{ email, status }] }, one entry per distinct submitted address, classified into created, duplicate_pending, already_member, or invalid_email. The organization-wide pending-invitation ceiling and the seat limit are each evaluated once against the count of addresses that would actually be created, never per address, and can still refuse the whole request with the same 409 conflict/seat-limit errors the single-invitation endpoint returns. grantAllPrivateProjects (default false; also accepted on the single-invitation endpoint) is a private-project access intent, not an immediate grant: when true, the invitee is granted a granted row on every private project at the moment they accept, replayed asynchronously from the invitation’s own stored intent — never evaluated at invite time. It is a no-op for the owner/admin roles, since those roles already bypass the private-project allowlist and would gain nothing from an explicit grant. The web app’s invite dialog surfaces this as the Access checkbox on both the Single and Bulk tabs: present and disabled with a stated reason whenever the selected role already bypasses the allowlist, so the control is never hidden — only inert where granting it would do nothing.

  • An invitation’s terminal email delivery outcome is now readable, and the dedicated suppressed-recipient error code is retired. GET /api/v1/orgs/{orgUlid}/invitations items gained emailDeliveryStatus: null while the invitation email is still queued or was delivered, "suppressed" if the recipient address is permanently undeliverable, or "failed" if delivery was retried until the outbox’s retry ceiling. This is a read model only — invitation email is delivered from an internal outbox after the response, so no invitation endpoint has a delivery verdict to report while its request is open. Breaking — the dedicated 409 { code: "email_recipient_suppressed" } conflict is removed from both invitation create and resend; a permanently undeliverable recipient no longer surfaces as a distinct error code on either endpoint — both now answer their existing tolerant success status (201/200) on every send outcome, and the outcome is reported later through emailDeliveryStatus instead.

  • A group’s project assignments can now be added in bulk, on the group plane. POST /api/v1/groups/{userGroupUlid}/projects assigns one or many projects to a group in a single request ({ projectUlids: string[] }, up to 200, .min(1)) — a new operation alongside the existing project-plane group allowlist endpoints above, not a replacement for them. It carries no organization role gate of its own: authorization runs per project, inside the same transaction, using the identical rule POST /api/v1/projects/{projectId}/access/groups already enforces. The write is all-or-nothing — an unresolvable (404) or unauthorized (403) project anywhere in the batch rejects the entire request and commits nothing, including a project earlier in the batch the caller was authorized for. Repeated ULIDs deduplicate to one assignment; an already-assigned project is never an error and never blocks the rest of the batch. See User groups.

  • Group members can now be added in bulk, in one request. POST /api/v1/groups/{userGroupUlid}/members now also accepts { userUlids: string[] } alongside the existing { userUlid: string } form — the same operation and path, not a new endpoint. A body carrying both keys, or neither, is rejected 422 { code: "validation_failed" }. The write is all-or-nothing: a departed (or never-a-member) userUlid anywhere in the array rejects the entire request with 404 not_found and adds nobody, and a request that would add zero new memberships (every listed user already a member) returns 409 { code: "conflict" } — matching the existing single-member behavior at cardinality one. Repeated ULIDs are silently deduplicated. Each newly added member still emits its own user_group.member_added event; a filtered (already-member) ULID emits none. See User groups.

  • A group’s initial project assignments can now be seeded atomically at creation. POST /api/v1/groups accepts an optional projectUlids array, following the same convention as the existing memberUserUlids: the group row and every seeded assignment commit or fail together, duplicates are silently deduplicated, and an unknown project ULID rejects the entire create with 404 not_found. A seeded assignment is never refused for targeting a public project. Neither field is accepted on PATCH /api/v1/groups/{userGroupUlid} — an unknown key rejects the whole request. See User groups.

  • User groups can now be assigned to a project’s access allowlist. GET/POST /api/v1/projects/{projectId}/access/groups and DELETE /api/v1/projects/{projectId}/access/groups/{userGroupUlid} mirror the individual member allowlist’s plane and authorization rule, and GET /api/v1/groups/{userGroupUlid}/projects reports the projects a group is assigned to, filtered to what the calling member can otherwise discover. projectCount on GET/POST /api/v1/groups and GET /api/v1/groups/{userGroupUlid} is no longer always 0 — it now reports the group’s real assignment count. See User groups and Project access.

  • An organization-scoped seats readout is now available. GET /api/v1/orgs/{orgUlid}/seat-usage reports the organization’s used seats and its effective seat limit — the same used-seat definition the seat-limit enforcement compares, never a recomputed or roster-derived count. Available to any authenticated member of the organization, with no role gate. Reading it never mutates anything.

  • An organization audit log export endpoint is now available. GET /api/v1/orgs/{orgUlid}/audit-events/export downloads the organization’s audit log as CSV for a required, bounded window (max 366 days). Gated on organization role (owner/admin) AND the auditExportEnabled entitlement — both refusals are 403 forbidden and indistinguishable on the wire. An exact count is taken before any row is read; a window over 25,000 events is refused 422 validation_failed naming both the actual count and the ceiling. Rate-limited to 10 exports per organization per hour, independently of the test-case export rate limit. The export never mutates or deletes anything.

  • An organization-scoped API result usage readout is now available. GET /api/v1/orgs/{orgUlid}/api-result-usage reports the organization’s counted machine-authored results for the current calendar month alongside its effective apiResultsPerMonth ceiling. Available to any authenticated member of the organization, with no role gate. It reports the authoritative counter’s own value — never a count recomputed by scanning results — and reading it never creates, resets or advances the counter. Once the calendar month turns, the readout reports 0 for the new period with no operator action or deploy.

  • Organization monthly API result limit is now enforced. Marking a run case (PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid} with a status), bulk-mark, and bulk-submit-result now reject a request that would push the organization’s counted machine-authored results for the current calendar month past its effective apiResultsPerMonth with 409 { code: "api_result_limit_exceeded" }. Only a request authenticated with an API token or an OAuth access token is counted; a session-cookie caller (including the product’s own web app) is never metered or refused on this ground. A bulk request is refused whole — it never partially applies. The refusal carries no Retry-After header and reveals neither your ceiling nor your current usage; it is distinct from 429 too_many_requests (a burst limiter) and from the existing 409 conflict a closed run already returns. The documented default is the Free tier’s monthly ceiling (5,000 counted results); an integration under that volume observes no change, and a higher-tier plan or an explicit override raises the ceiling for a specific organization, with no deploy, and the ceiling resets automatically at the start of the next calendar month.

  • Organization API plan rate limit is now enforced. Any /api/v1 request authenticated with an API token or an OAuth access token now counts against your organization’s per-minute plan ceiling. Exceeding it responds 429 { code: "too_many_requests" } with a Retry-After header holding the whole seconds remaining until the current 60-second window rolls — wait that long and the identical request is admitted, no retry backoff math required beyond that. This ceiling applies only to machine-authenticated traffic: a session-cookie caller (including the product’s own web app) is never refused on this ground. The documented default is 60 requests per minute; a higher-tier plan or an explicit override raises the ceiling for a specific organization, unblocking the identical previously-refused request immediately, with no deploy. A /mcp tools/call that dispatches one or more /api/v1 sub-requests counts each dispatched sub-request separately from — and in addition to — the existing /mcp burst limits, which remain unchanged.

  • The project limit now reaches projects an organization already holds. An organization over its effective project limit keeps only its most recently created projects open; every older one is locked, and every read and write against a locked project answers 403 { code: "project_locked" }, over /api/v1 and over MCP, for a session and for an API key alike. DELETE /api/v1/projects/{projectId} still succeeds — it is the only operation a locked project serves, and deleting one reopens the next-oldest as soon as the total is back inside the allowance. Nothing is deleted by the lock: GET /api/v1/projects still returns every project, each carrying lockedAt (epoch milliseconds, or null), and raising the allowance clears every stamp and reopens the projects unchanged. A public run share link belonging to a locked project collapses into the same byte-identical 404 { code: "not_found" } an unknown token earns, so a valid link cannot be used to detect the state. This supersedes the create-only behaviour described in the entry below.

  • Organization project limit is now enforced. POST /api/v1/projects now rejects a request that would push the organization’s used projects past its effective project limit with 409 { code: "project_limit_exceeded" }, distinct from the 409 { code: "conflict" } the same endpoint already returns for a duplicate project id. The documented default is the Free tier’s project ceiling (3); a higher-tier plan or an explicit override raises the limit and unblocks the identical previously-refused request with no deploy. At the time, no existing project was affected by a lowered limit and only the next create was refused; that is no longer true — see the project-lock entry above.

  • Inviting an address that is already a member is now refused. POST /api/v1/orgs/{orgUlid}/invitations now returns 409 { code: "conflict" } when the invited address already holds a membership in that organization, for every role including viewer. The correct operation for somebody who is already inside the organization is a role change (PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}), not an invitation. Previously only a second pending invitation for the same address was refused, so an address whose earlier invitation had already been accepted could be invited again — and while that invitation stayed pending, that person counted twice against the organization’s seat limit. The check is scoped to the inviting organization: an address that is a member of a different organization is still invitable.

  • Organization seat limit is now enforced. POST /api/v1/orgs/{orgUlid}/invitations (for a non-viewer role) and PATCH /api/v1/orgs/{orgUlid}/members/{userUlid} (when promoting a viewer to a non-viewer role) now reject a request that would push the organization’s used seats past its effective seat limit with 409 { code: "seat_limit_exceeded" }. A viewer role never consumes a seat and is never refused. Accepting an outstanding invitation is never refused on seat grounds — it only ever converts a reservation already counted.

  • Potentially breaking — Test case PATCH now enforces the write-role gate server-side. PATCH /api/v1/test-cases/{caseUlid} (and PATCH /defects/{defectUlid}) now reject a viewer-role caller with 403 forbidden before any write, matching the gate already applied to custom field values and other write endpoints. A viewer-scoped API key that previously succeeded on these two routes now receives 403.

  • Breaking — Runs auto-close; Reopen removed; rerun-failed generalized into clone. A run now closes itself automatically the instant every case carries a result (no client call needed); the latch is one-way — nothing done inside a closed run ever reopens it. POST /api/v1/runs/{runUlid}/reopen is removed (404). POST /api/v1/projects/{projectUlid}/runs/{runUlid}/rerun-failed is renamed to .../clone and generalized: body is now { title: string, cloneAssignees?: boolean, statusFilter?: TestOutcomeStatus[] } (previously { defaultAssigneeUlid? }, which only cloned failed/blocked/untested/retested cases). A closed, non-aborted run now accepts PATCH /runs/{runUlid}, case add/remove/mark/retry, and reconcile — only an aborted run stays terminal-frozen (409 conflict).

  • Runs — structured environment linkage. POST /api/v1/projects/{projectUlid}/runs and PATCH /api/v1/runs/{runUlid} now accept environmentId (ULID). Run responses expose a nested environment: { ulid, name, slug } | null object and environmentId. The legacy free-text environment string is still accepted on create for back-compat but should not be used in new integrations. GET .../runs/environments is retained but deprecated; filter by env=<environmentUlid> instead. See Environments API.

  • Breaking — Results ingestion is now an append-only log. PUT /api/v1/runs/{runUlid}/results/{caseUlid} (idempotent upsert) is removed. Record outcomes by marking run cases (PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}) or steps; GET /api/v1/runs/{runUlid}/results now returns the full marking history (multiple rows per case) with executedByUlid. Runs gained state (open/closed), description, defaultAssigneeUlid, and progress counters.

  • Projects — PATCH / DELETE restricted to owner/admin. member and viewer now receive 403 forbidden when mutating project metadata. Use an owner or admin token or session to rename the project code or delete a project.

  • Breaking — Test case classification fields moved to custom-field-values. POST/PATCH on /api/v1/projects/{projectId}/test-cases and /api/v1/test-cases/{caseUlid} no longer accept priority, severity, status, type, layer, behavior, automationStatus, isFlaky, preconditions, or postconditions. Read them from the customFieldValues array on the case response; write them via PUT /api/v1/test-cases/{caseUlid}/custom-field-values. The matching enum values continue to be the seeded options on the corresponding system custom fields.