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://probara.net
Local Workerhttp://localhost:8787

Example:

Terminal window
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://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, email_recipient_suppressed.

Table list endpoints (projects, milestones, runs, defects, organization members, invitations, 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.

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 resolved option 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.

    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.

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 organization role owner, admin, or 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.

Steps support optional attachments[] on create (POST .../test-cases) and on PUT .../steps. Images use a stage-and-commit flow:

  • 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://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://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://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 and DELETE on /api/v1/projects/{projectId} require organization membership role owner or admin. member and viewer receive 403 forbidden with error.code forbidden. GET and POST /api/v1/projects are 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 /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://probara.net/api/v1/projects?include=stats&memberUlid=01J...,01K..."

Interim behavior (v1). Project access is currently opt-out: every active organization member has access to every project. Until per-project access revocations exist, the team array reflects the whole organization and the memberUlid filter narrows nothing. Both will automatically start narrowing once project-level revocations are enabled — no client change required.

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://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://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).
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 (viewer403 forbidden) and emit audit events. Only an aborted run rejects these mutations with 409 conflict; a closed, non-aborted run still accepts them.

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://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 organization role owner, admin, or 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://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": 100000 }

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}/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.

  • 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 generous, so ordinary integrations observe no change; raising an organization’s override unblocks the identical previously-refused request immediately, 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 generous, so ordinary integrations observe no change; raising an organization’s override unblocks 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.

  • 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 unlimited, so no organization is refused until a lower limit is explicitly granted; raising the limit again unblocks the identical request with no deploy. No existing project is ever affected by a lowered limit — only the next create is refused.

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