Skip to content

Probara 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. To report from CI, use an app token instead: it belongs to the organization and may only make the calls the CI reporters make — the Probara CLI, which also syncs feature files, and the framework reporters.

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, storage_quota_exceeded, billing_provider_unavailable, organization_plan_required, run_case_assignee_locked, test_case_synced_read_only, project_locked, organization_pending_deletion.

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.

test_case_synced_read_only means the test case is synced from a .feature file in Git. Its scenario is read-only in Probara, so a write that would change it answers 409 with this code and writes nothing. Change the scenario in the file instead: see Synced cases.

organization_pending_deletion means the organization’s deletion is scheduled: an owner or Probara support requested it, or no member opened the organization in the web app for 12 months. For the 14-day grace period every write answers 403 { code: "organization_pending_deletion" } — through this API and through MCP, for an API key exactly as for a session — while every read, export included, keeps working. Nothing about the request is wrong and retrying will not help: the state ends only when the deletion is canceled, or when the organization is erased. No API or MCP call cancels it. A deletion scheduled for inactivity is canceled when a member opens the organization in the web app, and that member’s writes then go through; API keys and OAuth tokens do not count as activity, even while they are in use. GET /api/v1/orgs/current reports the state as pendingDeletion: { requestedAt, scheduledFor } (null otherwise). See Delete an organization.

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 a test case’s version history) 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.

Requests authenticated with an API token, an app token or an OAuth access token count against your organization’s plan limit: a number of requests per 60-second window, shared by every credential in the organization. Requests from the web app (session cookie) never count.

Every counted response states the limit and what is left of it, in the fields of the IETF RateLimit header fields draft:

RateLimit-Policy: "org";q=60;w=60
RateLimit: "org";r=42;t=17

q is the limit and w the window, in seconds. r is the number of requests left and t the seconds until the window resets. Past the limit, the request returns 429 too_many_requests with RateLimit: "org";r=0;t=… and a Retry-After header holding the same number of seconds: wait that long and send the request again. Pace your client on r and t to avoid the 429.

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

Long text is rich text: the descriptions of test cases, suites, projects, milestones, plans, environments, runs and defects; a test case step’s action, data and expected, and a step’s actual result; comments; result notes; review notes; and paragraph custom fields, preconditions and postconditions included. Titles, names, labels and other short values stay plain text, and so do Gherkin steps.

Each rich field has two forms, and every response carries both:

  • <field> is the text as Markdown. Integrations, MCP tools, imports and exports use it.
  • <field>Document is the same text as a document: a JSON tree whose attrs.version is 1 today. The web app edits this form, and it keeps every formatting detail.

An empty field answers null in both forms.

{
"description": "Open the **cart** and check the total.\n\n![Cart](attachment:01J...IMG)",
"descriptionDocument": {
"type": "doc",
"attrs": { "version": 1 },
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Open the " },
{ "type": "text", "text": "cart", "marks": [{ "type": "bold" }] },
{ "type": "text", "text": " and check the total." }
]
},
{
"type": "image",
"attrs": { "src": "attachment:01J...IMG", "alt": "Cart", "width": 800, "height": 600 }
}
]
}
}

Send one form per field, <field> or <field>Document. Sending both answers 422 validation_failed, and the message names the pair, for example send either description or descriptionDocument, not both. Projects, suites and review notes also set details.code to both_inputs. Send null in either form to clear the field.

Limits count the field’s plain text, the text without formatting or image references:

  • Test case, paragraph custom field and defect descriptions: 4000 characters.
  • Run descriptions and review notes: 2000 characters.
  • Comments: 5000 characters.
  • A step’s actual result: 20000 characters.

Every document is capped at 256 KiB, and so is the Markdown sent in <field>, which is checked before it is parsed. A larger value answers 422 with details.code too_large and details.limit 262144.

A refused value answers 422 validation_failed. details.code names the rule it breaks, and details.field names the field:

details.codeMeaning
invalid_documentThe document does not match the schema, or a mention is not a user ULID.
unsupported_versionattrs.version is not a version this API reads.
unsupported_linkA link uses a protocol other than http, https or mailto. Markdown keeps the link text and drops the link instead of refusing.
unsupported_imageAn image source is not attachment:<ULID>.
mentions_not_allowedThe field takes no mentions. Only comments do.
too_longThe plain text is over the field’s limit (details.limit).
too_largeThe document or Markdown is over 256 KiB (details.limit).
unknown_imageThe text names an image that was never staged or has expired. details.ulids lists them, and details.field is absent.

Headings deeper than level 3 read as level 3. A field takes paragraphs, headings, bold, italic, strikethrough, inline code, links, bullet, numbered and task lists, blockquotes, code blocks, tables and images.

Images sit inline in the text. Stage the file first, then save the field that uses it:

  1. Stage each file with POST /api/v1/images:stage: multipart/form-data with one or more file parts, at most 20 per request. Callers need the attachments.execute permission, which members, admins and owners hold. Each file must be PNG, JPEG or WebP, at most 10 MiB, with no side over 8192 pixels. The 201 response lists each image with its ulid and a previewUrl.
  2. Reference the image in the text: Markdown ![alt text](attachment:<ULID>), or a document image node whose attrs.src is attachment:<ULID>. Any other source is refused with unsupported_image.
  3. Save the field. The save copies the staged file to its permanent copy, scaled so its longest edge is at most 2048 pixels and stored as WebP, and the field holds it. An image stays stored while any text or stored revision references it. The save that drops its last reference deletes it. A staged image that no save references is removed after about 48 hours.

Only comments take mentions: @[ulid] in Markdown, or a mention node with attrs.id in a document. See Test case comments.

Webhooks, notification emails and the activity feed carry plain-text excerpts, never Markdown or documents. GitHub issue bodies are Markdown, and each image points at its stored asset URL. Jira descriptions and comments are converted to Atlassian Document Format: images become links, and mentions become @Name. Inbound Jira comments keep their formatting, and their media is dropped.

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.

Test runs and milestones carry a per-project number too (runNumber / milestoneNumber) and a displayId built from it. Unlike suites and cases, these display IDs are not project-prefixed — the {projectId} path segment scopes the lookup:

ResourceDisplay IDLookupSame shape as
Test runR-{n}GET /api/v1/projects/{projectId}/runs/by-display-id/R-12GET /api/v1/runs/{runUlid}
MilestoneM-{n}GET /api/v1/projects/{projectId}/milestones/by-display-id/M-3GET /api/v1/milestones/{milestoneUlid}

Numbers start at 1, increase with every run or milestone created in the project, and are never reused — a deleted run or milestone leaves a gap. A malformed ID (R-0, R-01, R-x) or the other resource’s prefix (M-3 on the runs lookup) returns 422 validation_failed; an unknown number, a number from another project, or a deleted milestone returns 404 not_found. The ULID stays the identifier on every other run and milestone endpoint. The web app uses the same IDs in its URLs, for example /projects/ACME/runs/R-12 and /projects/ACME/milestones/M-3.

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, 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', '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.

Create-time format. POST /api/v1/projects/{projectId}/test-cases accepts an optional stepFormat (classic or gherkin). Omitted, the case takes the project’s default step format, set with defaultStepFormat on PATCH /api/v1/projects/{projectId}.

Create-time values. POST /api/v1/projects/{projectId}/test-cases accepts an optional customFieldValues array in the same { fieldUlid, value } shape as PUT .../custom-field-values, so a case is created with its preconditions, postconditions or any other field in one call, at currentRevision 1:

{
"title": "Checkout with a saved card",
"customFieldValues": [{ "fieldUlid": "01J...PRECONDITIONS", "value": "A card is saved" }]
}

When the array is sent, every required field the project shows must be in it unless the field has a default, which is then stored. An unknown field, a field the project does not show, or an invalid value rejects the whole create with 422 validation_failed and no case is created. Omitting the array creates the case with every field at its default, as before. The values are recorded in the test_case.created event (metadata.customFieldChanges).

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" }]
}

The patch may also carry stepFormat (classic or gherkin). The case’s steps after the update, the steps sent with it or its current ones, must fit the new format, or the update is refused with 422. A gherkin step carries a keyword (Given, When, Then, And, But, or *) and sends no expected; a classic step sends no keyword.

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.

On a synced case the scenario is read-only. A PATCH that changes its title, description, steps, step format, preconditions value or automation key, or that drops one of its file tags, answers 409 test_case_synced_read_only and changes nothing. Its suite, assignee, priority and other custom fields stay writable, and so do the tags you add.

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. Each records one test_case.updated event when it changes something. PUT .../steps requires the same test case write permission (held by owner/admin/member); a viewer receives 403 forbidden.

Every test-case response carries stepFormat (classic or gherkin) and currentRevision. The revision starts at 1 and goes up by one each time the case’s content is saved: the consolidated PATCH, PUT .../steps, PUT and PATCH .../custom-field-values, converting steps to a shared step or detaching one, and an import that replaces the case. It also goes up by one when a shared step the case uses changes what the case shows: retitling it, replacing its items, or deleting it (a case whose item range leaves the change out keeps its revision). Archiving, restoring and cloning leave it unchanged; a clone starts at 1.

To avoid overwriting someone else’s edit, send the currentRevision you last read as expectedRevision:

  • In the JSON body of the consolidated PATCH, PUT .../steps, POST .../steps/convert-to-shared and both custom-field-value endpoints. On the PATCH, expectedRevision alone is not a save: at least one of patch, steps or customFieldValues is still required.
  • As a query parameter on the write without a body: POST .../steps/{stepUlid}/detach?expectedRevision=4.

If the case has moved on, the write is refused with 409 conflict and nothing is written:

{
"error": {
"code": "conflict",
"message": "the test case changed since it was read",
"details": { "reason": "stale_revision", "currentRevision": 5 }
}
}

Read the case again, reapply your change on top of it and retry. Without expectedRevision, the write is applied to whatever is stored, and the last write wins.

Convert and detach can also answer 409 conflict without details.reason when the steps or the shared step changed while the request ran. Nothing is written, and that request can be sent again as is.

Successful writes return the new revision so you can chain saves: the PATCH returns the case, and PUT .../steps, convert, detach and both custom-field-value endpoints add currentRevision to their body.

Mandatory test case review (202 and review_required)

Section titled “Mandatory test case review (202 and review_required)”

When a project has test case review turned on and mandatory (Pro or Enterprise plan; see Test case review settings), no write changes a case’s content directly:

  • POST .../test-cases and the consolidated PATCH /test-cases/{caseUlid} send their body for review instead. They answer 202 Accepted with the review request they opened (kind create or update, the project’s default reviewers, you as author), and the repository is unchanged until it is merged. A PATCH of a case that already has an open request answers 409 conflict with details.reason review_already_open.
  • POST /test-cases/{caseUlid}/archive and DELETE /test-cases/{caseUlid} answer 202 with an archive or delete review request instead of archiving or deleting the case, and POST .../test-cases/bulk-archive answers 202 with { created, skipped }: one archive request per case, and the cases skipped (an open request, already archived, not in the project), like POST .../test-case-reviews/bulk. POST /test-cases/{caseUlid}/unarchive is never sent to review.
  • Every other direct write of a case’s content is refused with 409 conflict and nothing is written: PUT .../steps, PUT and PATCH .../custom-field-values, converting steps to a shared step, detaching one, restoring a revision, and cloning a case or a suite into the project.
  • DELETE /suites/{suiteUlid} is refused with 409 conflict (review_required) while the suite or one of its descendants still holds a case that is not archived, whatever the caseDisposition; nothing is deleted. Archive those cases through review first. A suite with no cases, or only archived ones, is deleted as usual.
{
"error": {
"code": "conflict",
"message": "this project requires changes to test cases to go through review; send them as a review request",
"details": { "reason": "review_required" }
}
}

Send those changes as a review request instead, or as one consolidated PATCH. A CSV import opens one review request per case. While review is off or optional, and on plans without test case review, every write behaves as before.

Every test-case response carries reviewStatus: not_reviewed, in_review or approved. A case starts not_reviewed (approved when it was created by merging a review request), is in_review while an update request for it is open and approved once one is merged; declining a request puts back the status the case had before. Any direct change to the case — the consolidated PATCH, PUT .../steps, custom field values, restoring a revision, an import replacing it — makes it not_reviewed again, and a clone starts not_reviewed. Filter by it with GET .../test-cases/search?reviewStatus=approved,in_review or the PQL field reviewStatus.

Every save that moves currentRevision also stores that revision of the case. Read them with:

  • GET /api/v1/test-cases/{caseUlid}/revisions — the stored revisions, newest first, with cursor pagination (limit, cursor). Each item has revision, source (edit, import, shared_step, restore, review or git_sync, the Git sync rewriting a synced case), author (same shape as updatedBy), createdAt and isCurrent; the response also carries the case’s currentRevision. A case never saved since it was created has an empty list: the case itself is its only version.
  • GET /api/v1/test-cases/{caseUlid}/revisions/{revision} — one revision with snapshot, the case as that save left it: title, description, suite, milestone, automationKey, tags, customFieldValues and steps (shared steps expanded into the items they showed then). A revision the case has no stored copy of answers 404 not_found.

Option fields (select_single, radio, select_multi) keep the stored valueText (option ULIDs) and add selectedOptions, labeled from the field’s current options in the request locale. An option deleted since has "label": null:

{
"fieldUlid": "01J...TEAM",
"name": "Team",
"type": "select_single",
"valueText": "01J...FRONTEND",
"selectedOptions": [{ "ulid": "01J...FRONTEND", "label": null }]
}

Each step’s images array is always []: a step’s images are inline in its text, and a revision keeps them through its rich-text fields. Anyone who can read the case can read its history, archived cases included. MCP: list_test_case_revisions and get_test_case_revision.

An image stays stored while any text or stored revision references it. GET /api/v1/orgs/{orgUlid}/attachments lists each content image once, as a content_image item. attachments:bulk-delete does not accept content images and answers not_found: remove an image by removing it from the text that holds it.

Reading revisions and restoring one need a plan that includes version history (Pro and Enterprise). On other plans the three /revisions endpoints, and their MCP tools, answer 403 forbidden; GET /api/v1/orgs/current carries caseHistoryEnabled so you can check first. Every plan still stores a revision with each save, and currentRevision and expectedRevision work the same on every plan.

POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restore brings the case back to that revision. It is a save like any other: it needs the same role as the PATCH (owner, admin or member), creates a new revision with source restore, and records the activity event that save would record, with restoredFrom: test_case.updated, or test_case.moved when the restore changes the suite. The body is optional: a JSON object with an optional expectedRevision, and an empty body works like {}. A stale expectedRevision answers the 409 stale_revision conflict above and writes nothing.

What comes back:

  • Title, description, automation key, tags and steps (with their ULIDs and inline images). If another case now holds the automation key, the restore answers 409 conflict with details.reason automation_key_taken and writes nothing.
  • The suite and milestone, if they still exist. A deleted suite leaves the case in its current suite; a deleted milestone leaves it without one.
  • Custom fields, field by field. A deleted field and a deleted option are skipped. A field the revision had no value for is cleared, unless it is required: then it keeps its current value.
  • A shared step that still exists comes back as a reference with its item range, so it shows the shared step’s current content. A deleted shared step comes back as inline steps with the text and images the revision showed.

The response is the case, as the PATCH returns it, plus restore:

"restore": {
"fromRevision": 4,
"skipped": [
{ "kind": "milestone", "ulid": "01J...SPRINT", "name": "Sprint 12" },
{ "kind": "customFieldOption", "ulid": "01J...FRONTEND", "name": "Team" },
{ "kind": "sharedStepInlined", "ulid": "01J...LOGIN", "name": "Log in" },
{ "kind": "image", "ulid": "01J...SHOT", "name": "Open the cart" }
]
}

skipped[].kind is suite, milestone, customField, customFieldOption or sharedStepInlined; name is the item’s name at that revision (for customFieldOption, the field’s name). Restoring the revision the case is already at answers 422 validation_failed with details.reason already_current; a revision with no stored copy answers 404 not_found. If the deleted shared steps coming back as inline steps would give the case more than 500 steps, the restore answers 422 validation_failed with details.reason case_step_limit, limit and cases (the case, with the stepCount it would have), and writes nothing. A synced case cannot be restored to a revision: the request answers 409 test_case_synced_read_only and writes nothing. MCP: restore_test_case_revision.

A test case can carry an automationKey: the stable key CI tooling uses to link an automated test to the case, for example e2e/login.spec.ts > Login > rejects bad password. A case with a key counts as automated.

  • Set it on create (POST /api/v1/projects/{projectId}/test-cases, optional automationKey) or with patch.automationKey on the consolidated PATCH; null clears it. Every test-case response carries automationKey (null when unset).
  • The key is trimmed and must be 1–1024 characters with no control characters; a blank key is rejected with 422 validation_failed.
  • It is unique per project, archived cases included. A key another case already holds is refused with 409 conflict and details.reason automation_key_taken, and nothing is written. The same key may be used in different projects.
  • Clones never copy the key, and the web app shows it read-only on the case detail.
  • The key holds no parameters. A parameterized case has one key, and its run cases are told apart by their values; see Parameterized test cases.
  • On a synced case the key is read-only: it is the key of its scenario, and a save that changes it answers 409 test_case_synced_read_only.

Every test-case response carries source: manual for a case written in Probara, or git for a case that the Gherkin sync synced from a .feature file. A git case also carries sourceFile: the file’s repository-relative path, the line of its scenario, its https url when the sync knows one (otherwise null), and the tags the file gives the case. A manual case has sourceFile: null.

A git case is read-only for its title, description, steps and step format, preconditions value, automation key, and the tags its file gives it. A save that changes one of them, or drops one of those tags, answers 409 test_case_synced_read_only and writes nothing. The same code refuses a revision restore, a review request (single or bulk), and a conversion of its steps into a shared step. Its assignee, priority, custom fields other than preconditions, defects, links, comments, plan and run membership, suite and the tags you add stay writable.

Every test-case response carries author — who created the case — and updatedBy — who made the change that last set updatedAt. Each is a member, { "kind": "user", "ulid", "displayName", "avatarKey" }, or an API token, { "kind": "api_token", "ulid", "name", "app" }, the same shape as a run’s author: app is "junit", "playwright", "jest", "cypress", "vitest" or "cucumber" for an organization app token and null for a personal API token, which is named as itself rather than as its owner.

  • Creating a case — through this API, a CI report, an import, a clone or a Git sync — sets both to the caller. A clone is authored by whoever clones it, not by the source case’s author.
  • Every change that bumps updatedAt sets updatedBy and leaves author alone: the consolidated PATCH, archive, restore, bulk archive, a CI report linking an existing case to its automated test, an import replacing a case, a Git sync, and deleting a suite that moves or archives its cases.
  • Both are null for a case created before Probara recorded authorship, and for a member or token that has been erased.
{
"author": { "kind": "user", "ulid": "01J...ANA", "displayName": "Ana Pérez", "avatarKey": null },
"updatedBy": { "kind": "api_token", "ulid": "01J...TOKEN", "name": "Nightly", "app": "jest" }
}

Each classic step has three rich-text fields, action, data and expected, with the documents actionDocument, dataDocument and expectedDocument. Images go inline in these fields, as described in Rich text fields. Gherkin steps keep plain text and have no documents. Steps have no image gallery: their attachments array is always [], and naming one is refused.

On a synced case, step text is read-only: a save that changes it answers 409 test_case_synced_read_only.

  • 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. On a synced case the preconditions value is read-only: a save that changes it answers 409 test_case_synced_read_only. 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> }, plus an optional expectedRevision; no other 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.

Owners and admins may delete a custom field. Check first what the delete would erase:

Terminal window
curl -sS \
-H "Authorization: Bearer $PROBARA_API_TOKEN" \
-H "X-Organization-Id: $ORG_ULID" \
"https://app.probara.net/api/v1/orgs/$ORG_ULID/custom-fields/$FIELD_ULID/usage"

It returns { valueCount, projectCount }: the values stored for the field across test cases, test runs, and defects, and how many projects hold them.

DELETE /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid} deletes the definition. A field that still holds values is refused unless you add ?deleteValues=true, which permanently deletes every stored value together with the field.

  • 204 — The field (and, with deleteValues=true, its values) was deleted.
  • 409 field_has_values — The field holds values and deleteValues=true was not sent.
  • 403 system_field — System fields cannot be deleted, with or without deleteValues.
  • 404 — Unknown field, unknown org, or cross-tenant access.

A test case holds parameters (single, group, and shared entries, in order) and excludedCombinations. Case responses also carry combinationCount, the combinations the parameters produce before exclusions: 1 for a case without parameters. A run creates one run case for each combination that is not excluded. The rules and limits are in Parameterized test cases; a value over a limit is refused with 422 validation_failed.

FieldWhereMeaning
parametersCase create and update; case responsesThe parameters in order. A single has name and values; a group has name, columns, and rows; a shared entry has sharedParameterUlid. Responses resolve a shared entry to the values it holds.
excludedCombinationsCase create and update; case responsesEach excluded combination is a list of { name, value } pairs, with a value for every parameter of the case. A write replaces the whole set; omitting the field keeps it.
combinationCountCase responsesThe combinations before exclusions.

Shared parameters live under /api/v1/projects/{projectId}/shared-parameters:

Method and pathPurpose
GET /shared-parametersLists the shared parameters the project can use: its own, and the organization ones available to it.
POST /shared-parametersCreates one. scope is project or organization. An organization parameter may be limited with projectUlids, and needs the organization shared steps entitlement.
GET, PATCH, DELETE /shared-parameters/{sharedParameterUlid}Reads, changes, or deletes one. The kind and the scope are fixed at creation. A change reaches every case that uses the parameter. DELETE takes mode=convert_to_local (the default: each case keeps its own copy) or mode=remove (the reference leaves every case).
GET /shared-parameters/{sharedParameterUlid}/usageLists the projects whose cases use the parameter, with the case count in each.

Two endpoints work on a test case:

Method and pathPurpose
GET /api/v1/projects/{projectId}/test-cases/{caseUlid}/combinationsThe combination preview: combinationCount, the combinations left after exclusions, and the excludedCombinations.
POST /api/v1/projects/{projectId}/test-cases/{caseUlid}/parameters/{position}/detachConverts the shared entry at position into a local copy of its values. Returns the case’s parameters, exclusions, combination count, and the revision it stored.

Run responses carry the same combinations:

  • A run case has parameters, the combination it is ([] for a case without parameters). Its detail has siblings, the other run cases of the same case in the run, and reportedParameters, the parameters its current attempt reported. reportedParameters was the detail’s parameters before this change.
  • POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest refuses with 409 conflict and details.reason: combination_removed when the latest version no longer produces the run case’s combination. The bulk apply lists the same reason under skipped.
  • A result has runCaseUlid, the run case it belongs to (null once that run case was removed), and parameters, its combination.
  • GET /api/v1/runs/{runUlid}/case-keys lists each run case’s caseDisplayId, automationKey, and parameters, one entry per instance.
  • The public run share returns the authenticated run-case shape, so its run cases carry parameters too.

A test case’s Attachments section lists two kinds of entries: the images its texts reference, and loose files uploaded to the case. Both come from GET /api/v1/test-cases/{caseUlid}/attachments.

Loose files follow the defect evidence rules: any type is accepted except executables and scripts, empty files are refused, PNG, JPEG, and WebP images are converted to WebP with a thumbnail, and every other file is stored as sent. A case holds at most 20 loose files, and a request carries up to 20 file parts. Uploads charge the organization case_attachment storage counter.

Viewers may list attachments; only members and above may upload or delete.

GET/api/v1/test-cases/{caseUlid}/attachments

Returns { attachments: [...] }. The images come first, in text order: the description, then the paragraph custom fields, then the steps by position. An image has kind: "image" and its ulid, mime, dimensions, byteSize, url, thumbUrl, and createdAt, plus a field that says where it sits:

  • { "kind": "description" } for the description.
  • { "kind": "custom_field", "fieldUlid": "…", "name": "…" } for a paragraph custom field.
  • { "kind": "step", "stepUlid": "…", "position": 2, "part": "expected" } for a step’s action, data, or expected text. position is the step’s 1-based position.

Loose files follow, oldest first. Each has kind: "file" and the fields of a defect attachment: ulid, mime, dimensions, byteSize, originalFilename, uploadedBy, url, thumbUrl, and createdAt.

POST/api/v1/test-cases/{caseUlid}/attachments

multipart/form-data with one or more file parts. Returns 201 with { attachments: [...] }, one loose file per part. A blocked type, an empty file, an oversize file, or a request that would take the case past 20 loose files returns 422 validation_failed and stores nothing. An upload that would exceed the organization storage quota returns 413 storage_quota_exceeded. Emits test_case.attachment_added.

This is a direct upload, not a stage, and it ignores the Idempotency-Key header. A retried upload can store the same files twice: after a timeout or another ambiguous failure, list the case’s attachments before you retry.

DELETE/api/v1/test-cases/{caseUlid}/attachments/{attachmentUlid}

Returns 204. Removes the loose file, its stored object and its thumbnail, and refunds its storage bytes. Emits test_case.attachment_deleted. The ULID of an inline image returns 404 not_found: an image leaves the case when you remove it from its text.

  • Revisions do not hold loose files, so restoring a revision leaves them as they are.
  • Cloning a case does not copy its loose files. The copy starts with none.
  • Archiving a case keeps its loose files. Deleting a suite with caseDisposition: "deleteCases" archives its cases rather than deleting them, so their files stay with them.
  • Deleting a case, or a project, removes its loose files with their bytes and stored objects.

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/PATCH /api/v1/projects/{projectId}/review-settings read and change whether the project’s test case changes go through review, and how: mandatory, allowSelfMerge, approvalsRequired, autoMerge, onlyApprovedInRuns and defaultReviewerUserIds. Same read and write rules as the test-run settings. Pro or Enterprise plan only: on other plans both answer 403 forbidden. See Test case review settings.

POST /api/v1/projects/{projectId}/test-case-reviews sends a new case or a change to an existing case for review instead of saving it; GET .../test-case-reviews/{reviewUlid} reads one request. GET .../test-case-reviews lists them, GET .../{reviewUlid}/versions answers the proposed version next to the case as it is now, and approve, unapprove, request-changes, PATCH (edit), reviewers, decline and merge act on an open request; .../{reviewUlid}/comments holds its discussion. POST .../test-case-reviews/bulk sends cases for review as they are, and POST .../test-case-reviews/bulk-decision decides on several requests at once. See Test case review requests. A synced case takes no review request: creating one answers 409 test_case_synced_read_only, and a bulk request that names one is refused whole.

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 6 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?, plan?, milestoneId?, milestone?, configurationUlids?[], configurations?[], customFieldValues?[], source?, automated? } (source: see Run source; environment, milestone, plan and configurations: see Run references by name). Snapshots title + steps of each case. caseUlids (1–5000 when present) is required unless planUlid or plan is supplied (the run’s cases and per-case assignees seed from the plan’s current selection) or automated: true is sent (allows a run with no cases, for CI tools that report into it later; cases from caseUlids or planUlid are still added; request-only, not stored). 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/api/v1/projects/{projectId}/runs/by-display-id/{displayId}Resolves a run by its readable ID R-{n}; {projectId} is the project code. Same body as GET /api/v1/runs/{runUlid}. See Display ID lookups.
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. While the project’s autoCompleteRun run setting is on (the default), a run also closes itself once every case has a result recorded through the run-case endpoints. A CI report (POST /api/v1/projects/{projectId}/reports) never closes the run on its own: send options.close: true or call close. Calling close on an already-closed or aborted run returns 409 conflict (the latch is one-way); an unknown run returns 404.
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/api/v1/runs/{runUlid}/case-keysThe run’s cases as { caseDisplayId, automationKey }, cursor-paged; readable by an app token (see Run cases).
GET / POST/api/v1/runs/{runUlid}/casesList run cases; POST adds (and snapshots) more cases. Each case carries executedByApiToken, the API token that executed its current attempt (display only, never the assignee; see Run 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) — and the current attempt’s reported parameters, resultSteps and links (see Run cases). 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, never in a run an app token (a CI reporter) created, nor when an API token executed the current attempt (see Run cases).
PATCH/api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}Marks one step. The case keeps its own status: set it with a result.
GET/api/v1/runs/{runUlid}/resultsAppend-only marking history; a case may appear multiple times, ordered by executedAt. Each row names who executed it: executedByUlid (a member) and executedByApiToken ({ ulid, name, app } for a result an API token wrote, else null; see Who executed it).

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.

An automated run is CI reporting, so creating one (automated: true) needs a paid plan: on an organization without one it answers 403 forbidden for every credential, while a manual run is still created. An app token may only create automated runs.

When a project’s review settings have onlyApprovedInRuns on (Pro or Enterprise plan, with review enabled), a run takes in only test cases whose reviewStatus is approved. Cases you name in caseUlids (create, add cases, reconcile) are refused as a whole with 422 validation_failed, details.reason cases_not_approved and details.notApprovedCaseUlids listing them. Cases a run takes in on its own — a plan’s selection when caseUlids is omitted, or the source run of a clone — are filtered instead, and the answer carries notApprovedSkippedCount. See Run cases. CI reports are not affected.

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.
environmentThe environment by name, for CI tools. When it is a non-empty string, the server finds or creates an environment whose slug matches the slugified value and links the run to it. Giving both environmentId and environment answers 422 validation_failed and creates no run.

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.

An app token reads nothing but a run’s case keys, so a CI tool cannot look up the ULID of a milestone, a test plan or a configuration. POST /api/v1/projects/{projectId}/runs and the run a report creates therefore take each reference in one of two forms — except the plan, which a report’s new run takes by name only (plan):

ULID formName formThe name form matches
environmentIdenvironmentThe slug of a live environment of the project; a new environment is created when none matches (see Environment linkage on runs).
milestoneIdmilestoneA display id M-<n>, or else the exact, case-sensitive name of a milestone of the project (1–255 characters).
planUlid (run create only)planA display id PLAN-<n>, or else the exact, case-sensitive name of a test plan of the project (1–200 characters). Same effect as planUlid: the run is seeded with the plan’s selected cases.
configurationUlidsconfigurations[{ "group": "Browser", "name": "Chrome" }]: the exact, case-sensitive name of a configuration group of the project and of a value in it. At most 20 entries, each group at most once.
{
"name": "Nightly",
"automated": true,
"description": "Nightly regression on staging",
"environment": "staging",
"milestone": "M-3",
"plan": "Checkout regression",
"configurations": [{ "group": "Browser", "name": "Chrome" }]
}
  • A value shaped like a display id (M-3, PLAN-2) is only ever read as one: it never falls back to a name.
  • Deleted milestones, plans, configuration groups and values never match.
  • An unknown milestone, plan or configuration answers 422 validation_failed with details: { "field": "<milestone | plan | configurations>", "code": "unknown_reference" }, and nothing is written — not even the environment the same request would have created.
  • A plan whose selection is empty answers 422 validation_failed with details: { "field": "plan", "code": "empty_selection" } when it was given as plan ("field": "caseUlids" when it was given as planUlid), and nothing is written — not even the environment the same request would have created. No refused run create writes the environment it names.
  • Giving both forms of one reference (for example milestoneId and milestone) answers 422 validation_failed; a null counts as not given.
  • A report’s new run takes the plan as plan only: a planUlid in it is an unknown field and answers 422 validation_failed. It takes description (at most 2000 characters) too, like run create.

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:

  • runNumber, displayId — the run’s per-project number and its readable form R-{runNumber} (for example R-12). Resolve a display ID with GET /api/v1/projects/{projectId}/runs/by-display-id/{displayId} (see Display ID lookups).
  • 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": "...", "app": "junit" | "playwright" | "jest" | "cypress" | "vitest" | "cucumber" | null } — app names the Integrations app of an app token, and is null for an API token.
    • 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.
  • source — where a CI run came from, { provider, repository, branch, commit, commitAuthor, pullRequestNumber, workflow, ciRunId, ciRunAttempt, ciJob, buildUrl } with every key present (null when unknown), or null for a run without a source. See Run source.
  • githubBuild (on GET /runs/{runUlid} only) — the GitHub Actions build of a run launched from Probara, or null. See the GitHub API.

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

POST /api/v1/projects/{projectId}/runs (and a CI report’s run, see Reports) accepts an optional source recording where the run’s results came from:

{
"provider": "github_actions",
"repository": "acme/shop",
"branch": "feature/cart",
"commit": "9f2c1ab4e5d6",
"commitAuthor": "octocat",
"pullRequestNumber": 128,
"workflow": "E2E",
"ciRunId": "9876543210",
"ciRunAttempt": 1,
"ciJob": "test",
"buildUrl": "https://github.com/acme/shop/actions/runs/9876543210"
}
  • Every field is optional and trimmed. provider is github_actions or other; repository is owner/name; branch, commitAuthor, workflow and ciJob are 1–255 characters without control characters; commit is 1–64 printable characters without spaces (a hash or any revision id), and a 7–40 character hex SHA when provider is github_actions; ciRunId is 1–64 printable characters without spaces; pullRequestNumber and ciRunAttempt are positive integers; buildUrl is an http or https URL of at most 2048 characters. Anything else answers 422 validation_failed and creates no run.
  • null, {} or a source whose every field is null store no source: the run answers source: null.
  • The source is write-once. PATCH /api/v1/runs/{runUlid} does not accept it (422); the only later write is a CI report that reuses a run without a source, which gives it the report’s source. A run that already has one keeps it.
  • The web app shows the source under the run’s title: repository, branch, short commit, author, pull request, workflow, attempt and a link to the build.
  • A GitHub Actions source with a repository and a pullRequestNumber makes the run comment its results on that pull request when it is closed or aborted, as the project’s pull request comments setting allows, while the project’s GitHub App installation can reach the repository.
  • A run’s public share link never includes its source: the build URL may name a private CI host.

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.

POST /api/v1/projects/{projectId}/reports records a batch of automated results in one call — the endpoint CI tooling (a JUnit importer, a framework reporter) uses instead of orchestrating run creation, case matching and marking over several endpoints. {projectId} is the project code.

{
"run": { "name": "CI #482" },
"results": [
{
"caseDisplayId": "PRB-12",
"automationKey": "e2e/login.spec.ts > logs in",
"status": "passed",
"durationMs": 1840
},
{
"automationKey": "e2e/login.spec.ts > rejects bad password",
"title": "rejects bad password",
"suitePath": ["e2e", "login.spec.ts"],
"status": "failed",
"notes": "expected 401, got 500"
}
],
"options": { "createMissingCases": true, "close": false }
}
  • run is either { "ulid": "…" } to append to an open run of the project, or the fields of a new run, with the same rules as POST /api/v1/projects/{projectId}/runs — the name forms included, see Run references by name: { name, description?, environmentId? or environment?, milestoneId? or milestone?, plan?, configurationUlids? or configurations?, tags?, source? }. A plan (by name only — no planUlid) seeds the new run with the plan’s selected cases; one whose selection is empty answers 422 naming plan, with nothing written. Reusing a closed or aborted run answers 409 conflict; a run of another project answers 404.

  • run.source (optional on both shapes) is the run’s CI source, see Run source. A created run stores it. A reused run takes it only while it has none, so every chunk may send the same source: the first one wins and none is ever overwritten.

  • results carries 1–500 entries. Each needs a caseDisplayId or an automationKey (or both) and a status (passed | failed | skipped | blocked); durationMs, notes, an ISO executedAt, parameters, steps, links and case are optional (see What an entry can carry). Send larger suites in chunks of 500, reusing the run the first call returned. Unknown fields, or an entry with neither caseDisplayId nor automationKey, answer 422 validation_failed with nothing written.

  • Matching, per entry: a caseDisplayId decides when present — it matches that case or the entry is unmatched, never falling back to the key. When the matched case has no automation key and no other case holds the entry’s automationKey, the case adopts it: this is how an existing manual case gets linked the first time. Otherwise the entry is matched by automationKey. A case created earlier in the same batch, or one that just adopted the key, is reused, so entries sharing a key create one case.

  • Creating cases: an unknown key that comes with a title creates the case under suitePath (trimmed suite names, at most 10 levels) beneath options.suiteUlid or the project root, reusing suites of the same name. options.createMissingCases: false turns that off. An unknown options.suiteUlid answers 404 with nothing written.

  • Existing cases: a matched archived case is restored and records the result. To stop reporting a test, skip or remove it in your code. A case matched by automationKey whose entry has a suitePath is moved there, beneath options.suiteUlid or the project root, when it sits elsewhere, so the test’s place in your code decides its suite. A case matched by caseDisplayId, or managed by a Git sync, keeps its suite, and so does every case when options.createMissingCases is false. A report never changes the title, steps or fields of an existing case.

  • Recording: every matched entry appends one result with its own status, duration, notes, executedAt, parameters, steps and links; matched cases missing from the run are added to it, and each run case keeps the last outcome it received. The run is never completed automatically by a report, so the next chunk can still reach it; pass options.close: true on the last call to close it.

  • Atomic and idempotent: all writes (run, suites, cases, run cases, results, audit events) commit together or not at all. Send an Idempotency-Key so a retried batch is replayed instead of recorded twice. Each recorded result counts toward the monthly API result limit; a batch the limit cannot cover is refused whole with 409 api_result_limit_exceeded. Reusing an Idempotency-Key with a different body answers 422 validation_failed. Replays are stored for responses up to 64 KB, which the 500-entry limit and the bounded warnings keep every report under.

  • Sharded CI: parallel jobs may report into the same run at the same time. Each report commits against the run as it is at that moment, so no case is added to the run twice, positions never collide, and two shards that create the same new key share one case (the second is not flagged created). A report whose run was closed meanwhile answers 409 conflict with nothing written. Once a report has committed it always answers 201: if closing the run afterwards fails, the response shows the run still open — close it with POST /api/v1/runs/{runUlid}/close.

  • Requires the run execution and test case write permissions (owner/admin/member); a viewer receives 403 forbidden.

  • GitHub Actions jobs grouped into one run: a report without a run ulid whose run.source has provider: "github_actions", a repository and a ciRunId appends to the project’s run of that repository, CI run id and attempt (ciRunAttempt, 1 when omitted), and creates that run only when there is none. Every job of one workflow run can send the same run and they all land in one run. That run stays open until a final job closes it with options.close: true (results may then be empty) or POST /api/v1/runs/{runUlid}/close. A job reporting after the run was closed or aborted answers 409 ci_run_closed with details.runUlid, and nothing is written. A re-run of the workflow (another attempt) starts its own run. A run launched from Probara is named by its ulid in every attempt, since GitHub re-runs a workflow with the same inputs: a report with that ulid whose run.source names another attempt of the run’s workflow run appends to that attempt’s own run, created by its first report and named after the launched run (such as Nightly E2E · attempt 2). Read the run from the response’s run.ulid. Probara closes that run when the attempt finishes in GitHub. The final job’s request:

    {
    "run": {
    "name": "E2E #482",
    "source": {
    "provider": "github_actions",
    "repository": "acme/shop",
    "ciRunId": "9876543210",
    "ciRunAttempt": 1
    }
    },
    "results": [],
    "options": { "close": true }
    }
  • Paid plans only: reporting from CI is included in the paid plans. On an organization without one, a report answers 403 forbidden with nothing written, for every credential.

  • An app token of the JUnit XML app (the credential of the Probara CLI), of the Playwright app (the credential of the Probara Playwright reporter), of the Jest app (the credential of the Probara Jest reporter), of the Cypress app (the credential of the Probara Cypress reporter), of the Vitest app (the credential of the Probara Vitest reporter) or of the Cucumber app (the credential of the Probara Cucumber reporter and of the Probara CLI’s Gherkin sync) may send reports; the run it creates for them must be automated.

Besides its status, an entry can send what a framework reporter knows about the execution and about the case it creates:

{
"automationKey": "e2e/checkout.spec.ts > pays with a card",
"title": "Pays with a card",
"status": "failed",
"parameters": { "browser": "chromium", "user": "admin" },
"steps": [
{
"action": "Open the cart",
"status": "passed",
"durationMs": 420
},
{
"action": "Pay",
"status": "failed",
"durationMs": 1310,
"error": "Timed out waiting for the receipt",
"steps": [{ "action": "Fill the card form", "status": "passed" }]
}
],
"links": [
{ "url": "https://jira.example.com/browse/PAY-12", "name": "PAY-12" },
{ "url": "https://ci.example.com/builds/1834" }
],
"case": {
"description": "A signed-in user pays with a saved card.",
"tags": ["smoke", "payments"],
"fields": { "priority": "High", "severity": "Critical", "Component": "Checkout" },
"steps": [{ "action": "Pay with the saved card", "expected": "The receipt is shown" }]
}
}
FieldWhat it records
contextParametersThe names in parameters that are reporter context, such as a Playwright or Vitest project, a Cypress browser, or a retry attempt and repeat. Each name must be one the result reports and is listed once; otherwise the entry answers 422. Context is shown on the result and never becomes a parameter of a case the report creates.
parametersThe parameters the execution ran with, name to value, stored with the result in the order sent. At most 20; names and values are trimmed, a name has 1–100 characters and a value at most 500. Two names equal once trimmed, and the name __proto__, answer 422.
stepsThe steps the execution ran, as a tree: each step is { action, status, keyword?, durationMs?, error?, expected?, data?, steps? }, status as the entry’s, and keyword is the Gherkin keyword the step was written with (Given, When, Then, And, But or *; Cucumber sends one for each step). At most 200 steps per entry across all levels, nested at most 10 levels deep; action 1–2000 characters, expected and data at most 2000, error at most 4000. A step’s index is its position in a depth-first, pre-order walk of the tree, from 0.
linksLinks about the execution — an issue, a TMS page, a build log — as { url, name? }, stored with the result in the order sent. At most 20 per entry; url is an absolute http: or https: URL of at most 2048 characters and name 1–255 characters, both trimmed. Any other scheme (javascript:, data:, file:, ftp:) or a relative URL answers 422 with nothing written.
caseWhat a case the report creates starts with: description, tags (at most 50 names of 1–80 characters; unknown ones join the organization’s tag catalog), fields and steps (at most 500 { action, expected?, data? }, numbered in order).
  • case applies only on creation: only the entry that creates the case — the first entry of a new automation key, with its title — applies it. Entries that match an existing case, and later entries of the same key, ignore it without a warning: a report never modifies an existing case.
  • fields names at most 50 fields, matched ignoring case: a system field key (priority, severity, type, layer, behavior, status, preconditions, postconditions, is_flaky) or the title of a test case custom field visible to the project. A select or radio value names an option (ignoring case; ; separates the options of a multi-select); a checkbox is false for false, 0, no or n and true otherwise; other fields take the value as text, checked against the field type. A field or value that does not resolve — an unknown name, an unknown option, a value the field type refuses — is skipped and listed in warnings; it never fails the report. A field or option deleted while the report runs is skipped the same way (Field "Platforms" changed during the report and was skipped); for a multi-select, the whole field is skipped when any of its options was deleted.
  • An empty value is skipped silently: a value that is empty once trimmed sets nothing, a checkbox included (it is never stored as false), and adds no warning, whatever the name.
  • Totals per report: across all its entries, one report carries at most 10,000 result steps (steps, every level counted; REPORT_RESULT_STEPS_MAX_TOTAL), 10,000 case steps (case.steps; REPORT_CASE_STEPS_MAX_TOTAL) and 1,000 case tag names (case.tags; REPORT_CASE_TAGS_MAX_TOTAL). Case steps and tags count whether or not the entry creates its case. Links need no total of their own: 500 entries of at most 20 links already bound a report to 10,000. A larger report answers 422 validation_failed with nothing written, and the issue names the total and the report’s count — for example a report carries at most 1000 case tag names across all entries (this one has 1001); send the entries in smaller chunks. Split it into more chunks.
  • Step attachments: attach a file to a step by committing it with the step’s index as stepIndex (see Result attachments).
  • Read the parameters, steps and links back as parameters, resultSteps and links on the result’s attempt snapshot, and for the case’s current attempt on the run-case detail. The report response never echoes them. The web app shows each link as an external link, labelled by its name or else its URL.

A result’s parameters select the run case of the test case it reports, through the names the case declares:

  • A name the case declares selects the run case with exactly the values the result reports for the declared names. A name the case does not declare stays on the result for display, and never splits a run case.
  • If the run holds no run case with those values, Probara adds one, even when the case does not list those values. A result that reports none of the declared names lands on the run case with no values.
  • A case the report creates takes an Examples parameter from the data its results report: a single when they name one parameter, a group when they name several. It is created only when at least two distinct data sets name the same parameters. Context names (contextParameters) are left out of that parameter.

See Parameterized test cases.

options.assignFailedTo names the people who triage what the report leaves failed, by email:

{ "options": { "assignFailedTo": ["ana@example.com", "luis@example.com"] } }
  • Which run cases: each run case whose result from this report is failed — the last entry of the case in the report decides — and that has no assignee. They are assigned round-robin over the members, in the order given, following the order of the entries: three failed cases over two members go to the first, the second, then the first again. The round-robin starts again with the first member in every report: a suite sent as several reports into one run starts each of them with the first member.
  • Never overwritten: a run case that already has an assignee (set by someone, by the run’s default assignee or by an earlier report) keeps it and takes no turn. Passed, skipped and blocked run cases are never touched.
  • Emails: 1–20, trimmed and compared ignoring case; a repeated email counts once. An email resolves only to an active member of the organization who could work on the run case: their role executes runs (owner, admin or member; a viewer or a custom role does not), and the project is open to them (on a private project, an owner or admin, or someone granted access individually or through a group; never someone whose access to the project was revoked). So a case of a private project is never assigned, nor its run named in a notification, to someone outside it. One that does not resolve is skipped, and the response carries a single warning, always the first of warnings, that only counts them and never names which failed: assignFailedTo: 1 of 2 emails did not match a member who can be assigned in this project. When none resolves, nothing is assigned. The count does not hide membership from the caller: a report that names a single email learns whether it can be assigned. An app token is an organization credential that an owner or admin issues, and it can learn only whether an email it already knows can be assigned in that project. An email that is not an email answers 422 with nothing written.
  • Never fails the report: the assignment commits in the same transaction as the results, and an Idempotency-Key replay answers the stored response without assigning again. Each member’s assignment is audited like a bulk assignment (run.cases_bulk_assigned), attributed to the report’s user or app token, and the member is notified once for the run, as when assigned by hand — never the user who sent the report.

The 201 response answers each entry in request order — results[i] is the outcome of entry i:

{
"run": { "ulid": "01K…", "displayId": "R-7", "state": "open" },
"results": [
{ "outcome": "recorded", "caseUlid": "01K…", "resultUlid": "01K…" },
{ "outcome": "recorded", "caseUlid": "01K…", "resultUlid": "01K…", "created": true }
],
"summary": { "recorded": 2, "created": 1, "unmatched": 0 },
"warnings": ["Unknown field \"Sevrity\" was skipped"]
}

warnings is present only when the report skipped something without failing, such as a case field it could not resolve or one deleted while the report ran, or assignFailedTo emails that could not be assigned (that warning always comes first). It is deduplicated and holds at most 20 items of at most 300 characters (4 KB in all); when more were left out, the last item counts them.

An unmatched entry records nothing and carries a reason: case_not_found (no case holds the display id or key, and none was created), title_required (creation was allowed but the entry had no title) or invalid_display_id (malformed, or the display id of another project’s case). Use each resultUlid to attach files through the result attachment endpoints.

The same report is available to AI assistants as the MCP tool submit_report (see the MCP tool reference): its input is this request body plus project_id and an optional idempotency_key, and it answers this response.

POST /api/v1/projects/{projectId}/gherkin-sync makes the synced cases of a project match the scenarios of .feature files that a CI job parsed. {projectId} is the project code. The Probara CLI (probara sync gherkin) and the sync_gherkin_features MCP tool send this request. The sync guide explains the rules for customers.

The body carries the scope of the sync and the features with their scenarios. suiteUlid names the suite the feature suites go under (the project root when omitted), and dryRun: true answers the same outcome and writes nothing:

{
"scope": ["features/**/*.feature"],
"features": [
{
"path": "features/checkout/pay.feature",
"name": "Checkout",
"tags": ["payments"],
"scenarios": [
{
"automationKey": "features/checkout/pay.feature > Checkout > Pay with a card",
"name": "Pay with a card",
"line": 12,
"tags": ["smoke"],
"steps": [
{ "keyword": "Given", "text": "a cart with one item" },
{ "keyword": "Then", "text": "the order is paid" }
],
"url": "https://git.example.com/acme/shop/blob/main/features/checkout/pay.feature#L12"
}
]
}
],
"dryRun": false
}
  • scope is 1 to 20 globs, such as features/**/*.feature. Every feature’s path must match one of them, and only synced cases whose file matches scope can be archived.
  • A sync carries at most 1,000 scenarios. Each scenario has its automationKey (<path> > <Feature> > <Scenario>), its line, its steps with their keyword, and optionally its background, its examples (for a Scenario Outline), its rule and its url (an https link).

Each scenario is matched in this order. A probara:<case display ID> tag binds it to that case, whatever its key. Otherwise its automationKey finds the case that holds it. Otherwise a new key whose content hash (its steps, Background and Examples) equals that of exactly one synced case that disappeared in the same sync moves that case. Otherwise the scenario creates a case. A synced case whose scenario is gone is archived, and a scenario that matches an archived case restores it. A manual case that a key or a binding finds becomes a synced case. The sync guide has the examples.

The 200 answer has the outcome:

{
"dryRun": false,
"summary": {
"created": 1,
"updated": 0,
"moved": 0,
"restored": 0,
"archived": 0,
"unchanged": 0
},
"cases": [
{
"action": "created",
"automationKey": "features/checkout/pay.feature > Checkout > Pay with a card",
"previousAutomationKey": null,
"caseUlid": "01J...CASE",
"caseDisplayId": "SHOP-14",
"path": "features/checkout/pay.feature",
"line": 12
}
]
}

cases has one entry per scenario, in request order, then one per archived case. action is created, updated, moved, restored, archived or unchanged. previousAutomationKey is set for a move. caseUlid and caseDisplayId are null for a case that a dry run would create, and line is null for an archived case.

Refusals write nothing:

  • 403 forbidden: the plan does not include CI reporting, which the sync shares, or the role cannot write test cases or run executions (a viewer).
  • 404 not_found: the project, or the suiteUlid, does not exist.
  • 422 validation_failed: the body breaks the schema (an unknown field, a path that is not a repository-relative .feature path, more than 1,000 scenarios or 20 globs); a feature path matches none of scope; two scenarios share an automationKey (both lines are named); or a probara: tag names an unknown case or a case of another project, a scenario carries two such tags, or two scenarios bind one case.

A sync writes one test_case.synced audit event for the project, with its counts and scope, and one test_case.archived or test_case.restored event for each case it archives or restores. The cases it creates or updates have no event of their own, so they send no test_case.created or test_case.updated webhook.

GET /api/v1/orgs/{orgUlid}/members lists the organization’s members with page-based pagination. Add ?status=active to list the members who can sign in, ?status=deactivated to list the ones an admin deactivated, or leave status out to list everyone. Every row carries deactivatedAt: the epoch-millisecond instant an admin deactivated the member, or null while the member is active.

Admins deactivate a member instead of removing them. An owner or admin (org-members.manage) calls:

  • POST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivate ends the member’s access to the organization at once. Their personal API tokens, OAuth and MCP grants, single sign-on sessions and break-glass admissions for the organization are revoked, and their seat is freed. The membership, role, project access, groups and authorship stay.
  • POST /api/v1/orgs/{orgUlid}/members/{userUlid}/reactivate gives the access back and takes a seat. It needs a free seat: without one the request answers 409 seat_limit_exceeded and the member stays deactivated. The revoked tokens and grants are not restored, so the member needs new ones.

Both calls send a JSON body ({} is enough), answer 200 with { "member": … }, and record a member.deactivated or member.reactivated audit event. An owner cannot be deactivated and you cannot deactivate yourself (409 conflict), and a member who is already in the requested state gets the same answer.

DELETE /api/v1/orgs/{orgUlid}/members/{userUlid} no longer exists. A person who leaves the organization still removes their own membership with POST /api/v1/orgs/{orgUlid}/memberships/leave, and an erased account is removed from every organization it belonged to.

When a downgrade to Free leaves more members than its three seats, Probara deactivates the excess automatically: the newest members first, never the owner. Those audit events carry metadata.reason seat_overflow, and a deactivated member can be reactivated once a seat is free. Viewers count against the Free seats; on a plan with free viewers they do not.

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. A deactivated member holds no seat (see Organization members).

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 (an Enterprise 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.

  • Breaking — rich text fields and inline images. Long text fields take Markdown in <field> or a document in <field>Document, one per write, and responses carry both (see Rich text fields). Images are inline: stage them with POST /api/v1/images:stage and reference them in the text. The step, comment, shared step item and run step image galleries are removed, with their endpoints and MCP tools. Naming an attachments list on a step or a comment is refused. Result and defect file attachments are unchanged.

  • Gherkin Git sync and Cucumber results. New POST /api/v1/projects/{projectId}/gherkin-sync makes the synced cases of a project match .feature files (see Gherkin sync). Test cases carry source (manual or git) and, for a git case, sourceFile. A save that changes a synced case answers 409 test_case_synced_read_only. Reported result steps accept keyword, and the version history’s source includes git_sync.

  • Breaking — DELETE /api/v1/orgs/{orgUlid}/members/{userUlid} is removed. Deactivate the member with POST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivate instead, and reactivate them with .../reactivate. GET /api/v1/orgs/{orgUlid}/members accepts ?status=active|deactivated and every member carries deactivatedAt. See Organization members.

  • Plans are Free, Pro and Enterprise. Plan keys in subscription and organization payloads are free, pro and enterprise. POST /api/v1/orgs/{orgUlid}/subscription/trial accepts only tierKey pro. SCIM answers 403 with SCIM requires the Enterprise plan. on any other plan, and the audit log export requires the Enterprise plan. See Billing.

  • Organization two-factor requirement. GET /api/v1/orgs/current carries requireTwoFactor. While it is true, a member signed in to the web app whose account has no two-factor authentication is refused on organization routes with 403 organization_two_factor_required. API keys, app tokens and MCP clients are not affected. The owner changes it from Workspace → Settings; see Workspace.

  • Test case review: bulk actions and Assigned to me (Pro). New POST /api/v1/projects/{projectId}/test-case-reviews/bulk opens one review request per case (up to 200) that reviews the case as it is: currentVersionOnly is true, and merging it marks the case approved without writing a revision. New POST .../test-case-reviews/bulk-decision approves, requests changes on or declines up to 50 requests through the single actions, with a note required to request changes or decline. POST .../{reviewUlid}/decline takes an optional note, shown as declineNote. The list takes assignedToMe and needsMyReview, and the summary adds needsMyReviewCount. Review requests carry currentVersionOnly and declineNote. New MCP tools bulk_create_test_case_reviews and bulk_decide_test_case_reviews; decline_test_case_review takes note and list_test_case_reviews the two new filters. See Test case review requests.

  • Test case review: every write channel and the MCP actions (Pro). Under mandatory review, POST /api/v1/projects/{projectId}/test-cases and PATCH /api/v1/test-cases/{caseUlid} answer 202 with the review request they opened instead of 409 review_required, and a commit to POST .../imports/commit opens one review request per case: the import report gains counts.reviewRequestsCreated and reviewRequests, and the failure codes review_already_open and review_proposal_invalid. GET .../exports?scope=filtered takes the reviewStatus filter. New MCP tools list_test_case_reviews, get_test_case_review_versions, approve_test_case_review, unapprove_test_case_review, request_changes_test_case_review, update_test_case_review, set_test_case_review_reviewers, decline_test_case_review and merge_test_case_review; under mandatory review create_test_case and update_test_case return { sentToReview: true, message, review }. See Mandatory test case review.

  • Test case review: comments and notifications (Pro). New /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments endpoints (list, create, edit, delete, pin, unpin) with the test case comment shapes, recording test_case_review.commented, comment_updated, comment_deleted, comment_pinned and comment_unpinned. Five notification kinds in a new Review group: test_case_review.assigned, test_case_review.decided (with the decision in payload.context.status), test_case_review.edited, test_case_review.comment_created and test_case_review.comment_mention, with entityType test_case_review. Submitting a new case whose suite or milestone belongs to another project now answers 422 validation_failed, and two concurrent submissions for one case answer 409 review_already_open to the second. See Test case review requests.

  • Test case review: the Review actions (Pro). New GET /api/v1/projects/{projectId}/test-case-reviews (list, with status, kind, author and reviewer filters), GET .../test-case-reviews/summary, GET .../{reviewUlid}/versions (the proposed version), POST .../{reviewUlid}/approve, POST .../{reviewUlid}/unapprove, POST .../{reviewUlid}/request-changes, PATCH .../{reviewUlid} (edit the proposal), PUT .../{reviewUlid}/reviewers, POST .../{reviewUlid}/decline and POST .../{reviewUlid}/merge. A merged update is stored as a case revision with source review. Each action records a test_case_review.* audit event (approved, changes_requested, edited, reviewers_updated, declined, merged). The request read gains case, approvalCount, approvalsRequired, allowSelfMerge, conflict, and per reviewer current and note. PATCH .../review-settings now keeps a default reviewer who is hidden because they lost access, and a reordered reviewer list no longer records an audit event. See Test case review requests.

  • Test case review requests by display id (Pro). New GET /api/v1/projects/{projectId}/test-case-reviews/by-display-id/{displayId} and the MCP tool get_test_case_review_by_display_id read a request by its RV-{n} id, the id review URLs in the app now use. See Read a review request.

  • Test case review requests (Pro). New POST /api/v1/projects/{projectId}/test-case-reviews and GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}, and the MCP tools create_test_case_review and get_test_case_review, send a new case or a change to an existing case for review and read a request. Submitting records a test_case_review.created audit event. While a project’s review is mandatory, the direct case writes answer 409 conflict with details.reason review_required; see Mandatory test case review. Default reviewers must now also hold the test-cases.review permission, or PATCH .../review-settings answers 422. See Test case review requests.

  • Test case review settings (Pro). New GET/PATCH /api/v1/projects/{projectId}/review-settings and the MCP tools get_project_review_settings and update_project_review_settings read and change a project’s test case review settings. Changes record a project.review_settings_updated audit event. GET /api/v1/orgs/current carries caseReviewEnabled: true when the plan includes test case review (Pro or Enterprise), false otherwise. Custom roles gain the test-cases.review permission. See Test case review settings.

  • GitHub integration. New endpoints link GitHub issues to test cases, runs and defects, create issues from defects, and launch GitHub Actions workflows; see the GitHub API. Traceability reports accept GitHub repositories as their source. A run’s source gains provider, repository, commitAuthor, pullRequestNumber, workflow, ciRunId, ciRunAttempt and ciJob; GitHub Actions reports group into one run per workflow run attempt, and a report to a closed group answers 409 ci_run_closed. GET /runs/{runUlid} gains githubBuild.

  • Traceability reports replace the Jira coverage endpoints. GET /api/v1/projects/{projectId}/integrations/jira/coverage and GET /api/v1/projects/{projectId}/integrations/jira/unlinked-issues are removed, and so are the get_jira_coverage and list_jira_unlinked_issues MCP tools. A traceability report takes a snapshot of the requirements of the Jira projects and issue types you choose, and each version is generated in the background. See the traceability reports API and the MCP tool reference.

  • Saved queries and PQL. POST /api/v1/query, GET /api/v1/query/schema, and the saved query endpoints under /api/v1/queries (list, create, read, replace, delete and run) are available on the Pro and Enterprise plans. See PQL queries and Saved queries. Changes to saved queries are recorded in the audit log as saved_query.* events.

  • GET /api/v1/orgs/current carries managedPlan. The current-organization echo gains managedPlan: "enterprise" while an Enterprise contract governs the organization, "managed" while a comp or internal contract does, and null otherwise. The field is the label only; the contract’s price and terms are never exposed. Self-serve billing answers 409 { code: "conflict", details: { reason: "contract_active" } } while either label is set.

  • Records behind a widget value. GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/records and list_dashboard_widget_records list, page by page, the test cases, defects, runs, results or suites behind one value of a widget: a count, a created count, a distribution bucket or a time series bucket. The total is the value the widget shows for that selector. See Dashboard widgets.

  • Multi-select other buckets count each case once. In a distribution widget over a select_multi field, the other bucket counts the test cases that hold a folded option, each once. Before, a case that held two folded options was counted twice, so other could be higher than the number of cases in it. See Dashboard widgets.

  • Custom field values on test case create. POST /api/v1/projects/{projectId}/test-cases and create_test_case accept an optional customFieldValues array, so a case is created with its preconditions, postconditions or other fields in one call and one revision. See Test case custom field values.

  • Dashboard widget filters. Data widgets take filters on POST and PATCH of a dashboard widget and on create_dashboard_widget and update_dashboard_widget, and every widget response lists them with a display label per value. The fields a widget offers depend on what its metric counts; a filter value that names nothing you can use returns 422 validation_failed. See Dashboard widgets.

  • Deselecting the visible projects of a widget. On PATCH of a dashboard widget and update_dashboard_widget, a selected scope’s projectUlids may now be empty, so an editor can remove every project they can open while projects hidden from them remain. A selected scope that would be left with no project at all returns 422 validation_failed. See Dashboard widgets.

  • Distribution and time series widget data. The widget data endpoint and get_dashboard_widget_data now answer distribution widgets (test cases per value of a custom field, with a not_set bucket, multiValue for multi-select fields, and fieldRemoved once the field is deleted) and time_series widgets (day, week or month buckets on the midnights of the caller’s profile time zone, with one value per bucket for each series of the metric) and pass_rate widgets (one pooled value of the runs closed in the range, with its counts), instead of 422 unsupported widget type. See Dashboard widgets.

  • Dashboard widget data. GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/data and the MCP tool get_dashboard_widget_data return what a count or created_count widget shows, computed with the caller’s own project access. scope says how many projects were counted and how many were hidden, and a timed widget’s range says when the plan’s history retention cut it. See Dashboard widgets.

  • Dashboard widgets. New endpoints under /api/v1/dashboards/{dashboardUlid}/widgets list, add, change, reorder, and remove the widgets of a dashboard, from a fixed catalog (section title, count, created count, distribution, time series), with matching MCP tools such as create_dashboard_widget. A widget’s selected projects are always shown through the caller’s own project access, with a count of the hidden ones. See Dashboard widgets.

  • Public dashboards hold no group grants. POST /api/v1/dashboards/{dashboardUlid}/access/groups and the add_dashboard_access_group MCP tool refuse a public dashboard with 409 conflict, as a granted member grant already was. Making a dashboard public with PATCH /api/v1/dashboards/{dashboardUlid}/access now also removes its group grants, recording a group_revoked event for each. See Dashboards.

  • Dashboard access in one call. POST /api/v1/dashboards and create_dashboard accept members and groups for a private dashboard, granted in the same step as the dashboard is created. New PUT /api/v1/dashboards/{dashboardUlid} and the replace_dashboard_settings MCP tool set a dashboard’s name, description, visibility, granted members and groups together, all or nothing; revoked grants are left as they are. See Dashboards.

  • Private dashboards at creation. POST /api/v1/dashboards and the create_dashboard MCP tool accept an optional accessMode (public by default, or private), so a dashboard meant to be private is never visible to the rest of the organization. The dashboard.created audit event records the visibility it was created with. See Dashboards.

  • Dashboard access. New endpoints under /api/v1/dashboards/{dashboardUlid}/access make a dashboard public or private and manage its member grants (granted or revoked) and group grants, with matching MCP tools such as update_dashboard_access and set_dashboard_access_member. Only the dashboard’s creator and organization owners and admins can read or change them. Each change records a dashboard.access_changed audit event. See Dashboards.

  • Dashboards. New /api/v1/dashboards endpoints create, list, read, rename and delete organization dashboards (Pro and Enterprise plans), with the MCP tools list_dashboards, get_dashboard, create_dashboard, update_dashboard and delete_dashboard. A dashboard you cannot see answers 404; one you can see but did not create answers 403 on changes, unless you are an organization owner or admin. See Dashboards.

  • Dashboards plan flag. GET /api/v1/orgs/current carries dashboardsEnabled: true when the organization’s plan includes dashboards (Pro and Enterprise), false otherwise.

  • Images only earlier revisions hold. GET /api/v1/orgs/{orgUlid}/attachments lists them with surface test_case_revision and a new testCase field (null on other surfaces); attachments:bulk-delete deletes them, and deleting a step_attachment there removes it from every revision too, freeing its bytes at once. Revision snapshots mark such images deleted, and a restore reports them in skipped with kind image. See Version history. Both enums gained a value (surface test_case_revision, skipped[].kind image): a client that switches on them must accept values it does not know, since /api/v1 adds enum values without a new version.

  • Version history needs Pro or Enterprise. On other plans, GET /api/v1/test-cases/{caseUlid}/revisions, GET .../revisions/{revision} and POST .../revisions/{revision}/restore (and the MCP tools list_test_case_revisions, get_test_case_revision and restore_test_case_revision) answer 403 forbidden. Saving still stores a revision on every plan. GET /api/v1/orgs/current carries caseHistoryEnabled. See Version history.

  • Restore a test case revision. POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restore brings a case back to an earlier revision as a new revision (source restore) and lists what could not come back in restore.skipped. MCP tool restore_test_case_revision. See Restoring a revision.

  • Test case version history. GET /api/v1/test-cases/{caseUlid}/revisions lists a case’s stored revisions, newest first (cursor-paged), and GET .../revisions/{revision} returns one with its snapshot. MCP tools list_test_case_revisions and get_test_case_revision. See Version history.

  • Custom field configuration needs a paid plan. On Free, POST /api/v1/orgs/{orgUlid}/custom-fields and PATCH .../custom-fields/{fieldUlid} (system fields included) answer 403 forbidden. Listing fields, DELETE .../custom-fields/{fieldUlid}, POST .../custom-fields/{fieldUlid}/reset and every custom-field value write work on every plan. GET .../custom-fields carries customFieldsEnabled, true when the organization may create and edit fields.

  • The token that executed a result. Every result (GET /api/v1/runs/{runUlid}/results, the result of GET /api/v1/runs/{runUlid}/results/{resultUlid} and the public share) carries executedByApiToken: { ulid, name, app } for a result an API token wrote — app is the CI reporter of an app token, null for a personal API token — and null for a result recorded in the app or once the token is deleted. A result an app token reported used to name no executor at all. See Who executed it.

  • Run case keys for CI reporters. GET /api/v1/runs/{runUlid}/case-keys lists the cases now in a run as { caseDisplayId, automationKey } (automationKey is null when the case has none), cursor-paged (limit default 50, max 200). It is the first read an app token may make, so a framework reporter can run only the tests linked to an existing run’s cases; another organization’s run answers 404 not_found. See List a run’s case keys.

  • Assign failed results. A CI report accepts options.assignFailedTo (1–20 member emails): each run case the report leaves failed and unassigned is assigned round-robin to the members, starting again with the first member in every report; an existing assignee is never overwritten, and an email that is not an active member whose role can execute runs and who can access the project is only counted in warnings. See Assigning failed results.

  • Links on reported results. A CI report entry accepts links ([{ url, name? }], at most 20, absolute http:/https: URLs only; any other scheme answers 422). They are stored with the result and returned as links on the attempt snapshot and, for the current attempt, on the run-case detail. See What an entry can carry.

  • Framework reporters: steps, parameters, case fields and references by name. A CI report entry accepts parameters, a steps tree and case (description, tags, fields and steps applied only when the entry creates the case); the response may carry warnings, and one report holds at most 10,000 result steps, 10,000 case steps and 1,000 case tag names. POST /api/v1/projects/{projectId}/runs takes environment, milestone, plan and configurations by name, and the report’s new run takes them the same way (the plan only as plan); giving both forms of one reference answers 422 (previously environmentId won over environment), a plan given as plan with an empty selection answers 422 naming plan, and a refused run create no longer creates the environment it names. A result attachment commit takes stepIndex; the attempt snapshot returns parameters and resultSteps, and the run-case detail returns them for the current attempt. App tokens of the new Playwright app report like those of JUnit XML. See What an entry can carry and Run references by name.

  • App tokens report from CI. An organization app token (probara_app_…), created from the JUnit XML card in Workspace → Integrations, belongs to the organization rather than to a person and may only make the five calls the Probara CLI makes; everything else, /mcp included, answers 403 forbidden. Reporting from CI now needs a paid plan: on Free, a report and the creation of an automated run answer 403 forbidden for every credential. The api_token forms of run and plan author and of audit actor gain app, and organization attachment items gain uploaderApp. See App tokens.

  • Runs record their CI source. POST /api/v1/projects/{projectId}/runs and the CI report’s run accept an optional source: { branch?, commit?, buildUrl? }; every run response carries source (null when unset). Write-once: PATCH /api/v1/runs/{runUlid} does not accept it. See Run source.

  • One call records a CI run. POST /api/v1/projects/{projectId}/reports creates or reuses a run, matches up to 500 automated results to test cases by display ID or automation key, creates the missing cases and records one result per entry, atomically and under Idempotency-Key. See Reports.

  • Organizations nobody opens for 12 months are now scheduled for deletion. When no member has opened an organization in the web app for 12 months, Probara schedules its deletion with the usual 14-day grace period, so 403 { code: "organization_pending_deletion" } and a non-null pendingDeletion on GET /api/v1/orgs/current can now appear without an owner or Probara support requesting it. Requests with an API key or an OAuth token (MCP included) do not count as activity and do not cancel it; a member opening the organization in the web app does. Organizations with a paid subscription that will renew are never scheduled this way. See Organizations nobody opens for 12 months.

  • Test runs and milestones have readable IDs. Every run response now carries runNumber and displayId (R-12), and every milestone response milestoneNumber and displayId (M-3). Resolve them with GET /api/v1/projects/{projectId}/runs/by-display-id/{displayId} and GET /api/v1/projects/{projectId}/milestones/by-display-id/{displayId} (see Display ID lookups). Run links in notification emails now point to /projects/{code}/runs/R-{n}. Additive: ULID endpoints are unchanged.

  • Every attachment write now counts toward the organization storage quota. The steps writes (PUT /api/v1/test-cases/{caseUlid}/steps, POST /api/v1/projects/{projectId}/test-cases and PATCH /api/v1/test-cases/{caseUlid} with step attachments), comment create and edit on test cases and defects (POST/PATCH .../comments), run step results (PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}/result), clone (POST /api/v1/test-cases/{caseUlid}/clone, POST /api/v1/test-cases/bulk-clone, POST /api/v1/suites/{suiteUlid}/clone) and POST /api/v1/projects/{projectId}/avatar can now answer 413 { code: "storage_quota_exceeded" }, as result and defect attachment uploads already did. A refused request changes nothing, with one exception: when two concurrent step or comment saves compete for the last free bytes, the one refused may keep its non-file changes (step text, comment body, a new case). A request that adds no files, or whose net change frees space, is never refused. Hard-deleting a test case or a project, and removing steps, now frees the space of their files. The MCP tools that call these endpoints return the same error.

  • Organization ownership now moves by transfer, and an organization has exactly one owner. POST /api/v1/orgs/{orgUlid}/ownership-transfer with { "userUlid": "…" } makes another current member the owner and the calling owner an admin in one step, returning { previousOwner, newOwner }. Only the current owner may call it (403 { code: "forbidden" } otherwise); targeting yourself or a member who already owns the organization is 422 { code: "validation_failed" }, an unknown member is 404, a concurrent change is 409 { code: "conflict" }, and a viewer target needs a free seat (409 { code: "seat_limit_exceeded" }). The MCP tool transfer_org_ownership wraps it. Breaking — PATCH /api/v1/orgs/{orgUlid}/members/{userUlid} now refuses role: "owner" with 422 { code: "validation_failed" } for every caller, including the owner; use the transfer instead.

  • Organizations can be scheduled for deletion, and are read-only meanwhile. An owner schedules the deletion with POST /api/v1/orgs/{orgUlid}/deletion and cancels it with DELETE on the same path, both with a session cookie only — no API key or OAuth token can call them. For the 14-day grace period, every write to the organization answers the new 403 { code: "organization_pending_deletion" }, API keys and MCP included, and GET /api/v1/orgs/current gains pendingDeletion ({ requestedAt, scheduledFor } or null). Accepting an invitation to such an organization is refused with the same code.

  • An active contract now governs the plan, so self-serve billing answers 409 under it. While an organization holds an active contract, each self-serve billing mutation is refused with 409 { code: "conflict", details: { reason: "contract_active" } } before any call to the billing provider: POST /api/v1/orgs/{orgUlid}/subscription/checkout, PATCH /api/v1/orgs/{orgUlid}/subscription/seats, PATCH /api/v1/orgs/{orgUlid}/subscription/plan, DELETE /api/v1/orgs/{orgUlid}/subscription/pending-change, POST /api/v1/orgs/{orgUlid}/subscription/trial, POST /api/v1/orgs/{orgUlid}/subscription/downgrade-to-free, POST /api/v1/orgs/{orgUlid}/subscription/cancel and DELETE /api/v1/orgs/{orgUlid}/subscription/cancel. The reads and the customer portal (POST /api/v1/orgs/{orgUlid}/subscription/portal) stay available. The subscription readout gains managedPlan, { label, tierKey, committedSeats, endsAt }, while a contract governs the organization. label is "enterprise" or "managed", and managedPlan is null otherwise. The catalog lists only the published tiers; checkout or a plan change to any other tier answers 422 { code: "validation_failed" }.

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