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.
Base URL
Section titled “Base URL”All integrator routes use the prefix /api/v1.
| Environment | Base URL |
|---|---|
| Production | https://app.probara.net |
| Local Worker | http://localhost:8787 |
Example:
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/projects"Unversioned /api/... paths return 404 not_found.
Authentication
Section titled “Authentication”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.
Errors
Section titled “Errors”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.
Pagination
Section titled “Pagination”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.
Rate limits
Section titled “Rate limits”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=60RateLimit: "org";r=42;t=17q 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.
Versioning
Section titled “Versioning”Breaking API changes require a new major path (for example /api/v2). /api/v1 remains until explicitly deprecated.
Rich text fields
Section titled “Rich text fields”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>Documentis the same text as a document: a JSON tree whoseattrs.versionis1today. 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", "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 } } ] }}Writing a field
Section titled “Writing a field”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.code | Meaning |
|---|---|
invalid_document | The document does not match the schema, or a mention is not a user ULID. |
unsupported_version | attrs.version is not a version this API reads. |
unsupported_link | A link uses a protocol other than http, https or mailto. Markdown keeps the link text and drops the link instead of refusing. |
unsupported_image | An image source is not attachment:<ULID>. |
mentions_not_allowed | The field takes no mentions. Only comments do. |
too_long | The plain text is over the field’s limit (details.limit). |
too_large | The document or Markdown is over 256 KiB (details.limit). |
unknown_image | The 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
Section titled “Images”Images sit inline in the text. Stage the file first, then save the field that uses it:
- Stage each file with
POST /api/v1/images:stage:multipart/form-datawith one or morefileparts, at most 20 per request. Callers need theattachments.executepermission, which members, admins and owners hold. Each file must be PNG, JPEG or WebP, at most 10 MiB, with no side over 8192 pixels. The201response lists each image with itsulidand apreviewUrl. - Reference the image in the text: Markdown
, or a documentimagenode whoseattrs.srcisattachment:<ULID>. Any other source is refused withunsupported_image. - 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.
Where the text goes
Section titled “Where the text goes”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.
Display ID lookups
Section titled “Display ID lookups”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 exampleACME).{displayId}must match{projectId}-S{positiveInteger}(for exampleACME-S2). Returns the sameSuiteResponseshape asGET /api/v1/suites/{suiteUlid}.GET /api/v1/projects/{projectId}/test-cases/by-display-id/{displayId}—{displayId}must match{projectId}-{positiveInteger}(for exampleACME-14). Returns the sameTestCaseResponseshape asGET /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:
| Resource | Display ID | Lookup | Same shape as |
|---|---|---|---|
| Test run | R-{n} | GET /api/v1/projects/{projectId}/runs/by-display-id/R-12 | GET /api/v1/runs/{runUlid} |
| Milestone | M-{n} | GET /api/v1/projects/{projectId}/milestones/by-display-id/M-3 | GET /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.
Archived test cases (Trash)
Section titled “Archived test cases (Trash)”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
- Restoring archived cases — user-facing walkthrough of the paginated Trash view and cross-page selection.
Test case custom field values
Section titled “Test case custom field values”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 acustomFieldValuesarray with one entry per visible field for the case’s project. Missing stored values materialize to the field’sdefaultValue(ornull). -
GET /api/v1/projects/{projectId}/test-casesalso embedscustomFieldValueson 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
fieldUlidandvalue:systemKey: the field definition’ssystemKey('priority','severity','status','type','layer','behavior','is_flaky','preconditions','postconditions') forgroup = 'system'fields, ornullforgroup = 'custom'.optionName: the selected option’sname('High','Critical','Active', …) forselect_single/radiovalues when the value is a valid option ULID;nullfor other types or when the value isnull.optionNameis resolved for the request locale the same way as field definitions are now bilingual below:Accept-Language: esreturns 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 definitions are now bilingual
Section titled “Field definitions are now bilingual”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.
Consolidated test case update (PATCH)
Section titled “Consolidated test case update (PATCH)”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.
Concurrent edits (currentRevision)
Section titled “Concurrent edits (currentRevision)”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-sharedand both custom-field-value endpoints. On thePATCH,expectedRevisionalone is not a save: at least one ofpatch,stepsorcustomFieldValuesis 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-casesand the consolidatedPATCH /test-cases/{caseUlid}send their body for review instead. They answer202 Acceptedwith the review request they opened (kindcreateorupdate, the project’s default reviewers, you as author), and the repository is unchanged until it is merged. APATCHof a case that already has an open request answers409 conflictwithdetails.reasonreview_already_open.POST /test-cases/{caseUlid}/archiveandDELETE /test-cases/{caseUlid}answer202with anarchiveordeletereview request instead of archiving or deleting the case, andPOST .../test-cases/bulk-archiveanswers202with{ created, skipped }: onearchiverequest per case, and the cases skipped (an open request, already archived, not in the project), likePOST .../test-case-reviews/bulk.POST /test-cases/{caseUlid}/unarchiveis never sent to review.- Every other direct write of a case’s content is refused with
409 conflictand nothing is written:PUT .../steps,PUTandPATCH .../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 with409 conflict(review_required) while the suite or one of its descendants still holds a case that is not archived, whatever thecaseDisposition; 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.
Review status (reviewStatus)
Section titled “Review status (reviewStatus)”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.
Version history (/revisions)
Section titled “Version history (/revisions)”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 hasrevision,source(edit,import,shared_step,restore,revieworgit_sync, the Git sync rewriting a synced case),author(same shape asupdatedBy),createdAtandisCurrent; the response also carries the case’scurrentRevision. 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 withsnapshot, the case as that save left it:title,description,suite,milestone,automationKey,tags,customFieldValuesandsteps(shared steps expanded into the items they showed then). A revision the case has no stored copy of answers404 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.
Restoring a revision
Section titled “Restoring a revision”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 conflictwithdetails.reasonautomation_key_takenand 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.
Automation key
Section titled “Automation key”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, optionalautomationKey) or withpatch.automationKeyon the consolidatedPATCH;nullclears it. Every test-case response carriesautomationKey(nullwhen 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 conflictanddetails.reasonautomation_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.
Synced cases
Section titled “Synced cases”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.
Created and updated by
Section titled “Created and updated by”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
updatedAtsetsupdatedByand leavesauthoralone: the consolidatedPATCH, 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
nullfor 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" }}Case step text and images
Section titled “Case step text and images”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-valuesatomically replaces the case’s value set:
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).
Update a single custom-field value
Section titled “Update a single custom-field value”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.
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"].
Update a custom field
Section titled “Update a custom field”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.
Reset a system field to seed defaults
Section titled “Reset a system field to seed defaults”Owners and admins may restore a system custom field definition to its seeded state:
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 bysystemKey). 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.
Delete a custom field
Section titled “Delete a custom field”Owners and admins may delete a custom field. Check first what the delete would erase:
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 anddeleteValues=truewas not sent. - 403
system_field— System fields cannot be deleted, with or withoutdeleteValues. - 404 — Unknown field, unknown org, or cross-tenant access.
Parameterized test cases
Section titled “Parameterized test cases”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.
| Field | Where | Meaning |
|---|---|---|
parameters | Case create and update; case responses | The 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. |
excludedCombinations | Case create and update; case responses | Each 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. |
combinationCount | Case responses | The combinations before exclusions. |
Shared parameters live under /api/v1/projects/{projectId}/shared-parameters:
| Method and path | Purpose |
|---|---|
GET /shared-parameters | Lists the shared parameters the project can use: its own, and the organization ones available to it. |
POST /shared-parameters | Creates 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}/usage | Lists the projects whose cases use the parameter, with the case count in each. |
Two endpoints work on a test case:
| Method and path | Purpose |
|---|---|
GET /api/v1/projects/{projectId}/test-cases/{caseUlid}/combinations | The combination preview: combinationCount, the combinations left after exclusions, and the excludedCombinations. |
POST /api/v1/projects/{projectId}/test-cases/{caseUlid}/parameters/{position}/detach | Converts 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 hassiblings, the other run cases of the same case in the run, andreportedParameters, the parameters its current attempt reported.reportedParameterswas the detail’sparametersbefore this change. POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latestrefuses with409 conflictanddetails.reason: combination_removedwhen the latest version no longer produces the run case’s combination. The bulk apply lists the same reason underskipped.- A result has
runCaseUlid, the run case it belongs to (nullonce that run case was removed), andparameters, its combination. GET /api/v1/runs/{runUlid}/case-keyslists each run case’scaseDisplayId,automationKey, andparameters, one entry per instance.- The public run share returns the authenticated run-case shape, so its run cases carry
parameterstoo.
Test case attachments
Section titled “Test case attachments”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.
/api/v1/test-cases/{caseUlid}/attachmentsReturns { 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.positionis 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.
Upload
Section titled “Upload”/api/v1/test-cases/{caseUlid}/attachmentsmultipart/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
Section titled “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.
Loose files and the case lifecycle
Section titled “Loose files and the case lifecycle”- 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.
Projects
Section titled “Projects”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).
| Method | Path | Body | Response |
|---|---|---|---|
POST | /api/v1/projects/{projectId}/avatar | multipart/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.
Access control
Section titled “Access control”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:
| Method | Path | Notes |
|---|---|---|
GET | /api/v1/projects/{projectId}/access/groups | List the groups assigned to the project’s allowlist |
POST | /api/v1/projects/{projectId}/access/groups | Assign 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}/projects | The reverse view: the projects a given group is assigned to (see below) |
POST | /api/v1/groups/{userGroupUlid}/projects | Assign 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.
Test-run settings
Section titled “Test-run settings”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).
Test case review settings
Section titled “Test case review settings”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.
Test case review requests
Section titled “Test case review requests”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.
List: optional stats and member filter
Section titled “List: optional stats and member filter”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 param | Notes |
|---|---|
include=stats | Embeds an optional stats object, a team array, and a teamCount integer on each list item. The only accepted value is stats. |
memberUlid | Comma-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:
| Field | Type | Meaning |
|---|---|---|
stats.cases | integer | Non-archived test cases |
stats.runs | integer | All test runs |
stats.runsInProgress | integer | Test runs with state = open |
stats.defectsUnresolved | integer | Defects with status open or in_progress |
stats.milestones | integer | Live (non-deleted) milestones |
team | array | Lean, 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 }. |
teamCount | integer | Full 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.
curl -sS \ -H "Authorization: Bearer $PROBARA_API_TOKEN" \ "https://app.probara.net/api/v1/projects?include=stats&memberUlid=01J...,01K..."
team/teamCount/memberUlidare 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 agrantedallowlist row, intersected with active organization membership; members who reach the project only through the organizationowner/adminrole bypass hold no grant and are correctly absent from the team, even though they do appear in the project’s access member list.memberUlidnarrows against this same mode-aware set, so it has real effect on both public and private projects.
List: presence and state filters
Section titled “List: presence and state filters”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 param | Options | Meaning |
|---|---|---|
runs | without | Projects with no test runs |
active | Projects with at least one open run (state = open) | |
any | Projects with at least one run (any state) | |
defects | has | Projects with at least one unresolved defect (status open or in_progress) |
without | Projects with no unresolved defect (resolved-only or none) | |
milestones | has | Projects with at least one live (non-deleted) milestone |
without | Projects with no live milestone | |
cases | has | Projects with at least one active (non-archived) test case |
without | Projects with no active case |
defectsmeans unresolved. Thedefectsfilter only ever considers defects whose status isopenorin_progress— the same definition asstats.defectsUnresolved. Resolved or closed defects never make a project matchdefects=has.
All four filters are tenant-isolated: they only ever consider rows in your own organization.
# 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"Test runs & results
Section titled “Test runs & results”A run snapshots the cases selected into it. Create a run with the cases to execute, then read and mutate its cases:
| Method | Path | Notes |
|---|---|---|
POST | /api/v1/projects/{projectUlid}/runs | Body { 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}/runs | Each 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/environments | Deprecated. 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}/close | Completes 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-keys | The run’s cases as { caseDisplayId, automationKey }, cursor-paged; readable by an app token (see Run cases). |
GET / POST | /api/v1/runs/{runUlid}/cases | List 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-mark | Body { 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-assign | Body { caseUlids, assigneeUlid: string | null }. Same atomicity and response shape; emits run.cases_bulk_assigned. |
POST | /api/v1/runs/{runUlid}/cases/bulk-retry | Body { caseUlids }. Resets selected cases (and their steps) to untested; emits run.cases_bulk_retried. |
POST | /api/v1/runs/{runUlid}/cases/bulk-remove | Body { 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}/open | Auto-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}/results | Append-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.
Only approved cases in runs
Section titled “Only approved cases in 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.
Environment linkage on runs
Section titled “Environment linkage on runs”Runs can be linked to a structured Environment entity managed by the project.
Create (POST /api/v1/projects/{projectUlid}/runs)
| Field | Notes |
|---|---|
environmentId | Optional 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. |
environment | The 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})
| Field | Notes |
|---|---|
environmentId | Optional 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
| Field | Type | Notes |
|---|---|---|
environmentId | string (ULID) | null | ULID of the linked environment, or null when not linked. |
environment | { ulid, name, slug } | null | Summary of the linked environment object, or null when not linked. |
environmentName | string | null | Deprecated. The raw free-text value of the legacy environment TEXT column. Use environment.name instead. |
Run references by name
Section titled “Run references by name”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 form | Name form | The name form matches |
|---|---|---|
environmentId | environment | The slug of a live environment of the project; a new environment is created when none matches (see Environment linkage on runs). |
milestoneId | milestone | A display id M-<n>, or else the exact, case-sensitive name of a milestone of the project (1–255 characters). |
planUlid (run create only) | plan | A 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. |
configurationUlids | configurations | [{ "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_failedwithdetails: { "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_failedwithdetails: { "field": "plan", "code": "empty_selection" }when it was given asplan("field": "caseUlids"when it was given asplanUlid), 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
milestoneIdandmilestone) answers422 validation_failed; anullcounts as not given. - A report’s new run takes the plan as
planonly: aplanUlidin it is an unknown field and answers422 validation_failed. It takesdescription(at most 2000 characters) too, like run create.
Run list filters
Section titled “Run list filters”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:
| Parameter | Notes |
|---|---|
q | Case-insensitive substring search over run name and environment |
status | Comma-separated projected status: in_progress, passed, failed |
env | Repeatable 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. |
authorUlid | User ULID of the run author. API-token authored runs do not match user authors. |
assigneeUlid | Default-assignee user ULID, or empty for runs without a default assignee. |
cf | Repeatable 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=50Run response shape
Section titled “Run response shape”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 formR-{runNumber}(for exampleR-12). Resolve a display ID withGET /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 oftest_run_cases.status:{ passed, failed, blocked, skipped, untested }.Σ counts = total. Use this to render result bars; do not synthesize frompassRate.firstResultAt,lastResultAt— epoch ms of the first and lasttest_results.executed_atregistered on the run; bothnulluntil the first result lands. UselastResultAt − firstResultAtfor the wall-clock elapsed of the run; for an in-progress run usenow − firstResultAt.totalDurationMs— sum oftest_results.duration_msfor the run (results without a measured duration contribute0).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 }—appnames the Integrations app of an app token, and isnullfor 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 }.configurationUlidisnullonce the underlying configuration value has been hard-deleted;groupName/valueNameare 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 everyRunResponse— 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 (nullwhen unknown), ornullfor a run without a source. See Run source.githubBuild(onGET /runs/{runUlid}only) — the GitHub Actions build of a run launched from Probara, ornull. 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}.
Run source
Section titled “Run source”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.
providerisgithub_actionsorother;repositoryisowner/name;branch,commitAuthor,workflowandciJobare 1–255 characters without control characters;commitis 1–64 printable characters without spaces (a hash or any revision id), and a 7–40 character hex SHA whenproviderisgithub_actions;ciRunIdis 1–64 printable characters without spaces;pullRequestNumberandciRunAttemptare positive integers;buildUrlis anhttporhttpsURL of at most 2048 characters. Anything else answers422 validation_failedand creates no run. null,{}or a source whose every field isnullstore no source: the run answerssource: 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
repositoryand apullRequestNumbermakes 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.
Run custom field values
Section titled “Run custom field values”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 acustomFieldValuesarray with one entry per visibletest_runfield for the run’s project. Missing stored values materialize to the field’sdefaultValue(ornull). 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-valuesatomically replaces the run’s value set:
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:
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.
Reports
Section titled “Reports”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 }}-
runis either{ "ulid": "…" }to append to an open run of the project, or the fields of a new run, with the same rules asPOST /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? }. Aplan(by name only — noplanUlid) seeds the new run with the plan’s selected cases; one whose selection is empty answers422namingplan, with nothing written. Reusing a closed or aborted run answers409 conflict; a run of another project answers404. -
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. -
resultscarries 1–500 entries. Each needs acaseDisplayIdor anautomationKey(or both) and astatus(passed|failed|skipped|blocked);durationMs,notes, an ISOexecutedAt,parameters,steps,linksandcaseare 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 neithercaseDisplayIdnorautomationKey, answer422 validation_failedwith nothing written. -
Matching, per entry: a
caseDisplayIddecides 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’sautomationKey, the case adopts it: this is how an existing manual case gets linked the first time. Otherwise the entry is matched byautomationKey. 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
titlecreates the case undersuitePath(trimmed suite names, at most 10 levels) beneathoptions.suiteUlidor the project root, reusing suites of the same name.options.createMissingCases: falseturns that off. An unknownoptions.suiteUlidanswers404with 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
automationKeywhose entry has asuitePathis moved there, beneathoptions.suiteUlidor the project root, when it sits elsewhere, so the test’s place in your code decides its suite. A case matched bycaseDisplayId, or managed by a Git sync, keeps its suite, and so does every case whenoptions.createMissingCasesisfalse. 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; passoptions.close: trueon 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-Keyso 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 with409 api_result_limit_exceeded. Reusing anIdempotency-Keywith a different body answers422 validation_failed. Replays are stored for responses up to 64 KB, which the 500-entry limit and the boundedwarningskeep 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 answers409 conflictwith nothing written. Once a report has committed it always answers201: if closing the run afterwards fails, the response shows the run stillopen— close it withPOST /api/v1/runs/{runUlid}/close. -
Requires the run execution and test case write permissions (
owner/admin/member); aviewerreceives403 forbidden. -
GitHub Actions jobs grouped into one run: a report without a run
ulidwhoserun.sourcehasprovider: "github_actions", arepositoryand aciRunIdappends 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 samerunand they all land in one run. That run stays open until a final job closes it withoptions.close: true(results may then be empty) orPOST /api/v1/runs/{runUlid}/close. A job reporting after the run was closed or aborted answers409 ci_run_closedwithdetails.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 itsulidin every attempt, since GitHub re-runs a workflow with the same inputs: a report with thatulidwhoserun.sourcenames 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 asNightly E2E · attempt 2). Read the run from the response’srun.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 forbiddenwith 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.
What an entry can carry
Section titled “What an entry can carry”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" }] }}| Field | What it records |
|---|---|
contextParameters | The 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. |
parameters | The 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. |
steps | The 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. |
links | Links 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. |
case | What 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). |
caseapplies only on creation: only the entry that creates the case — the first entry of a new automation key, with itstitle— 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.fieldsnames 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 forfalse,0,noornand 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 inwarnings; 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 answers422 validation_failedwith nothing written, and the issue names the total and the report’s count — for examplea 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,resultStepsandlinkson 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 itsnameor else its URL.
Parameters of a report
Section titled “Parameters of a report”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
Examplesparameter 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.
Assigning failed results
Section titled “Assigning failed results”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 answers422with nothing written. - Never fails the report: the assignment commits in the same transaction as the results, and an
Idempotency-Keyreplay 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.
Response
Section titled “Response”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.
Gherkin sync
Section titled “Gherkin sync”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}scopeis 1 to 20 globs, such asfeatures/**/*.feature. Every feature’spathmust match one of them, and only synced cases whose file matchesscopecan be archived.- A sync carries at most 1,000 scenarios. Each scenario has its
automationKey(<path> > <Feature> > <Scenario>), itsline, itsstepswith theirkeyword, and optionally itsbackground, itsexamples(for a Scenario Outline), itsruleand itsurl(anhttpslink).
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 (aviewer).404 not_found: the project, or thesuiteUlid, does not exist.422 validation_failed: the body breaks the schema (an unknown field, apaththat is not a repository-relative.featurepath, more than 1,000 scenarios or 20 globs); a featurepathmatches none ofscope; two scenarios share anautomationKey(both lines are named); or aprobara: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.
Organization members
Section titled “Organization members”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}/deactivateends 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}/reactivategives the access back and takes a seat. It needs a free seat: without one the request answers409 seat_limit_exceededand 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.
Organization API result usage
Section titled “Organization API result usage”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.
Organization seat usage
Section titled “Organization seat usage”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).
Organization audit log export
Section titled “Organization audit log export”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.
Changelog
Section titled “Changelog”-
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 withPOST /api/v1/images:stageand 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 anattachmentslist 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-syncmakes the synced cases of a project match.featurefiles (see Gherkin sync). Test cases carrysource(manualorgit) and, for agitcase,sourceFile. A save that changes a synced case answers409 test_case_synced_read_only. Reported result steps acceptkeyword, and the version history’ssourceincludesgit_sync. -
Breaking —
DELETE /api/v1/orgs/{orgUlid}/members/{userUlid}is removed. Deactivate the member withPOST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivateinstead, and reactivate them with.../reactivate.GET /api/v1/orgs/{orgUlid}/membersaccepts?status=active|deactivatedand every member carriesdeactivatedAt. See Organization members. -
Plans are Free, Pro and Enterprise. Plan keys in subscription and organization payloads are
free,proandenterprise.POST /api/v1/orgs/{orgUlid}/subscription/trialaccepts onlytierKeypro. SCIM answers403withSCIM 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/currentcarriesrequireTwoFactor. While it istrue, a member signed in to the web app whose account has no two-factor authentication is refused on organization routes with403 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/bulkopens one review request per case (up to 200) that reviews the case as it is:currentVersionOnlyistrue, and merging it marks the caseapprovedwithout writing a revision. NewPOST .../test-case-reviews/bulk-decisionapproves, requests changes on or declines up to 50 requests through the single actions, with anoterequired to request changes or decline.POST .../{reviewUlid}/declinetakes an optionalnote, shown asdeclineNote. The list takesassignedToMeandneedsMyReview, and the summary addsneedsMyReviewCount. Review requests carrycurrentVersionOnlyanddeclineNote. New MCP toolsbulk_create_test_case_reviewsandbulk_decide_test_case_reviews;decline_test_case_reviewtakesnoteandlist_test_case_reviewsthe 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-casesandPATCH /api/v1/test-cases/{caseUlid}answer202with the review request they opened instead of409 review_required, and a commit toPOST .../imports/commitopens one review request per case: the import report gainscounts.reviewRequestsCreatedandreviewRequests, and the failure codesreview_already_openandreview_proposal_invalid.GET .../exports?scope=filteredtakes thereviewStatusfilter. New MCP toolslist_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_reviewandmerge_test_case_review; under mandatory reviewcreate_test_caseandupdate_test_casereturn{ 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}/commentsendpoints (list, create, edit, delete, pin, unpin) with the test case comment shapes, recordingtest_case_review.commented,comment_updated,comment_deleted,comment_pinnedandcomment_unpinned. Five notification kinds in a new Review group:test_case_review.assigned,test_case_review.decided(with the decision inpayload.context.status),test_case_review.edited,test_case_review.comment_createdandtest_case_review.comment_mention, withentityTypetest_case_review. Submitting a new case whose suite or milestone belongs to another project now answers422 validation_failed, and two concurrent submissions for one case answer409 review_already_opento the second. See Test case review requests. -
Test case review: the Review actions (Pro). New
GET /api/v1/projects/{projectId}/test-case-reviews(list, withstatus,kind,authorandreviewerfilters),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}/declineandPOST .../{reviewUlid}/merge. A merged update is stored as a case revision withsourcereview. Each action records atest_case_review.*audit event (approved,changes_requested,edited,reviewers_updated,declined,merged). The request read gainscase,approvalCount,approvalsRequired,allowSelfMerge,conflict, and per reviewercurrentandnote.PATCH .../review-settingsnow 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 toolget_test_case_review_by_display_idread a request by itsRV-{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-reviewsandGET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}, and the MCP toolscreate_test_case_reviewandget_test_case_review, send a new case or a change to an existing case for review and read a request. Submitting records atest_case_review.createdaudit event. While a project’s review is mandatory, the direct case writes answer409 conflictwithdetails.reasonreview_required; see Mandatory test case review. Default reviewers must now also hold thetest-cases.reviewpermission, orPATCH .../review-settingsanswers422. See Test case review requests. -
Test case review settings (Pro). New
GET/PATCH /api/v1/projects/{projectId}/review-settingsand the MCP toolsget_project_review_settingsandupdate_project_review_settingsread and change a project’s test case review settings. Changes record aproject.review_settings_updatedaudit event.GET /api/v1/orgs/currentcarriescaseReviewEnabled:truewhen the plan includes test case review (Pro or Enterprise),falseotherwise. Custom roles gain thetest-cases.reviewpermission. 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
sourcegainsprovider,repository,commitAuthor,pullRequestNumber,workflow,ciRunId,ciRunAttemptandciJob; GitHub Actions reports group into one run per workflow run attempt, and a report to a closed group answers409 ci_run_closed.GET /runs/{runUlid}gainsgithubBuild. -
Traceability reports replace the Jira coverage endpoints.
GET /api/v1/projects/{projectId}/integrations/jira/coverageandGET /api/v1/projects/{projectId}/integrations/jira/unlinked-issuesare removed, and so are theget_jira_coverageandlist_jira_unlinked_issuesMCP 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 assaved_query.*events. -
GET /api/v1/orgs/currentcarriesmanagedPlan. The current-organization echo gainsmanagedPlan:"enterprise"while an Enterprise contract governs the organization,"managed"while a comp or internal contract does, andnullotherwise. The field is the label only; the contract’s price and terms are never exposed. Self-serve billing answers409 { code: "conflict", details: { reason: "contract_active" } }while either label is set. -
Records behind a widget value.
GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/recordsandlist_dashboard_widget_recordslist, 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. Thetotalis the value the widget shows for that selector. See Dashboard widgets. -
Multi-select
otherbuckets count each case once. In adistributionwidget over aselect_multifield, theotherbucket counts the test cases that hold a folded option, each once. Before, a case that held two folded options was counted twice, soothercould 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-casesandcreate_test_caseaccept an optionalcustomFieldValuesarray, 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
filtersonPOSTandPATCHof a dashboard widget and oncreate_dashboard_widgetandupdate_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 returns422 validation_failed. See Dashboard widgets. -
Deselecting the visible projects of a widget. On
PATCHof a dashboard widget andupdate_dashboard_widget, aselectedscope’sprojectUlidsmay now be empty, so an editor can remove every project they can open while projects hidden from them remain. Aselectedscope that would be left with no project at all returns422 validation_failed. See Dashboard widgets. -
Distribution and time series widget data. The widget data endpoint and
get_dashboard_widget_datanow answerdistributionwidgets (test cases per value of a custom field, with anot_setbucket,multiValuefor multi-select fields, andfieldRemovedonce the field is deleted) andtime_serieswidgets (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) andpass_ratewidgets (one pooled value of the runs closed in the range, with its counts), instead of422 unsupported widget type. See Dashboard widgets. -
Dashboard widget data.
GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/dataand the MCP toolget_dashboard_widget_datareturn what acountorcreated_countwidget shows, computed with the caller’s own project access.scopesays how many projects were counted and how many were hidden, and a timed widget’srangesays when the plan’s history retention cut it. See Dashboard widgets. -
Dashboard widgets. New endpoints under
/api/v1/dashboards/{dashboardUlid}/widgetslist, 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 ascreate_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/groupsand theadd_dashboard_access_groupMCP tool refuse a public dashboard with409 conflict, as agrantedmember grant already was. Making a dashboard public withPATCH /api/v1/dashboards/{dashboardUlid}/accessnow also removes its group grants, recording agroup_revokedevent for each. See Dashboards. -
Dashboard access in one call.
POST /api/v1/dashboardsandcreate_dashboardacceptmembersandgroupsfor a private dashboard, granted in the same step as the dashboard is created. NewPUT /api/v1/dashboards/{dashboardUlid}and thereplace_dashboard_settingsMCP tool set a dashboard’s name, description, visibility, granted members and groups together, all or nothing;revokedgrants are left as they are. See Dashboards. -
Private dashboards at creation.
POST /api/v1/dashboardsand thecreate_dashboardMCP tool accept an optionalaccessMode(publicby default, orprivate), so a dashboard meant to be private is never visible to the rest of the organization. Thedashboard.createdaudit event records the visibility it was created with. See Dashboards. -
Dashboard access. New endpoints under
/api/v1/dashboards/{dashboardUlid}/accessmake a dashboard public or private and manage its member grants (grantedorrevoked) and group grants, with matching MCP tools such asupdate_dashboard_accessandset_dashboard_access_member. Only the dashboard’s creator and organization owners and admins can read or change them. Each change records adashboard.access_changedaudit event. See Dashboards. -
Dashboards. New
/api/v1/dashboardsendpoints create, list, read, rename and delete organization dashboards (Pro and Enterprise plans), with the MCP toolslist_dashboards,get_dashboard,create_dashboard,update_dashboardanddelete_dashboard. A dashboard you cannot see answers404; one you can see but did not create answers403on changes, unless you are an organization owner or admin. See Dashboards. -
Dashboards plan flag.
GET /api/v1/orgs/currentcarriesdashboardsEnabled:truewhen the organization’s plan includes dashboards (Pro and Enterprise),falseotherwise. -
Images only earlier revisions hold.
GET /api/v1/orgs/{orgUlid}/attachmentslists them withsurfacetest_case_revisionand a newtestCasefield (nullon other surfaces);attachments:bulk-deletedeletes them, and deleting astep_attachmentthere removes it from every revision too, freeing its bytes at once. Revision snapshots mark such imagesdeleted, and a restore reports them inskippedwithkindimage. See Version history. Both enums gained a value (surfacetest_case_revision,skipped[].kindimage): a client that switches on them must accept values it does not know, since/api/v1adds 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}andPOST .../revisions/{revision}/restore(and the MCP toolslist_test_case_revisions,get_test_case_revisionandrestore_test_case_revision) answer403 forbidden. Saving still stores a revision on every plan.GET /api/v1/orgs/currentcarriescaseHistoryEnabled. See Version history. -
Restore a test case revision.
POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restorebrings a case back to an earlier revision as a new revision (sourcerestore) and lists what could not come back inrestore.skipped. MCP toolrestore_test_case_revision. See Restoring a revision. -
Test case version history.
GET /api/v1/test-cases/{caseUlid}/revisionslists a case’s stored revisions, newest first (cursor-paged), andGET .../revisions/{revision}returns one with itssnapshot. MCP toolslist_test_case_revisionsandget_test_case_revision. See Version history. -
Custom field configuration needs a paid plan. On Free,
POST /api/v1/orgs/{orgUlid}/custom-fieldsandPATCH .../custom-fields/{fieldUlid}(system fields included) answer403 forbidden. Listing fields,DELETE .../custom-fields/{fieldUlid},POST .../custom-fields/{fieldUlid}/resetand every custom-field value write work on every plan.GET .../custom-fieldscarriescustomFieldsEnabled,truewhen the organization may create and edit fields. -
The token that executed a result. Every result (
GET /api/v1/runs/{runUlid}/results, theresultofGET /api/v1/runs/{runUlid}/results/{resultUlid}and the public share) carriesexecutedByApiToken:{ ulid, name, app }for a result an API token wrote —appis the CI reporter of an app token,nullfor a personal API token — andnullfor 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-keyslists the cases now in a run as{ caseDisplayId, automationKey }(automationKeyisnullwhen the case has none), cursor-paged (limitdefault 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 answers404 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 leavesfailedand 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 inwarnings. See Assigning failed results. -
Links on reported results. A CI report entry accepts
links([{ url, name? }], at most 20, absolutehttp:/https:URLs only; any other scheme answers422). They are stored with the result and returned aslinkson 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, astepstree andcase(description, tags, fields and steps applied only when the entry creates the case); the response may carrywarnings, 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}/runstakesenvironment,milestone,planandconfigurationsby name, and the report’s new run takes them the same way (the plan only asplan); giving both forms of one reference answers422(previouslyenvironmentIdwon overenvironment), a plan given asplanwith an empty selection answers422namingplan, and a refused run create no longer creates the environment it names. A result attachment commit takesstepIndex; the attempt snapshot returnsparametersandresultSteps, 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,/mcpincluded, answers403 forbidden. Reporting from CI now needs a paid plan: on Free, a report and the creation of an automated run answer403 forbiddenfor every credential. Theapi_tokenforms of run and planauthorand of auditactorgainapp, and organization attachment items gainuploaderApp. See App tokens. -
Runs record their CI source.
POST /api/v1/projects/{projectId}/runsand the CI report’srunaccept an optionalsource: { branch?, commit?, buildUrl? }; every run response carriessource(nullwhen 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}/reportscreates 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 underIdempotency-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-nullpendingDeletiononGET /api/v1/orgs/currentcan 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
runNumberanddisplayId(R-12), and every milestone responsemilestoneNumberanddisplayId(M-3). Resolve them withGET /api/v1/projects/{projectId}/runs/by-display-id/{displayId}andGET /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-casesandPATCH /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) andPOST /api/v1/projects/{projectId}/avatarcan now answer413 { 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-transferwith{ "userUlid": "…" }makes another current member the owner and the calling owner anadminin 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 is422 { code: "validation_failed" }, an unknown member is404, a concurrent change is409 { code: "conflict" }, and aviewertarget needs a free seat (409 { code: "seat_limit_exceeded" }). The MCP tooltransfer_org_ownershipwraps it. Breaking —PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}now refusesrole: "owner"with422 { 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}/deletionand cancels it withDELETEon 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 new403 { code: "organization_pending_deletion" }, API keys and MCP included, andGET /api/v1/orgs/currentgainspendingDeletion({ requestedAt, scheduledFor }ornull). 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
409under it. While an organization holds an active contract, each self-serve billing mutation is refused with409 { 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/cancelandDELETE /api/v1/orgs/{orgUlid}/subscription/cancel. The reads and the customer portal (POST /api/v1/orgs/{orgUlid}/subscription/portal) stay available. The subscription readout gainsmanagedPlan,{ label, tierKey, committedSeats, endsAt }, while a contract governs the organization.labelis"enterprise"or"managed", andmanagedPlanisnullotherwise. The catalog lists only the published tiers; checkout or a plan change to any other tier answers422 { code: "validation_failed" }. -
A scheduled subscription change can now be cancelled through the API.
DELETE /api/v1/orgs/{orgUlid}/subscription/pending-changediscards 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 answers409 { code: "conflict" }without sending any change to the billing provider. Requiresorg-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 fromPOST /api/v1/orgs/{orgUlid}/subscription/checkoutandPATCH /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 freeviewerrole 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-settingsandGET/PATCH /api/v1/orgs/{orgUlid}/run-settingsexpose twelve settings governing marking, closed-run writes, and run-creation defaults. Eleven resolve throughproject → organization → code default;defaultAssigneeUserIdis 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 (allowResultsInClosedRunsdefaultstrue) — an organization that wants the previous, stricter behavior sets it tofalse; (2) marking a run case now rejects with403 { code: "run_case_assignee_locked" }when the project’sassigneeResultLocksetting 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 with422 validation_failed(details.code: "elapsed_ms_required") when the project’stimeTrackingsetting isrequiredand the request omitselapsedMs— 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/bulkaccepts{ emails: string[], role, locale?, grantAllPrivateProjects? }— one role and one locale apply to the WHOLE list, never per address.emailsaccepts 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 intocreated,duplicate_pending,already_member, orinvalid_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 same409conflict/seat-limit errors the single-invitation endpoint returns.grantAllPrivateProjects(defaultfalse; also accepted on the single-invitation endpoint) is a private-project access intent, not an immediate grant: whentrue, the invitee is granted agrantedrow 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 theowner/adminroles, 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}/invitationsitems gainedemailDeliveryStatus:nullwhile 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 dedicated409 { 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 throughemailDeliveryStatusinstead. -
A group’s project assignments can now be added in bulk, on the group plane.
POST /api/v1/groups/{userGroupUlid}/projectsassigns 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 rulePOST /api/v1/projects/{projectId}/access/groupsalready 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}/membersnow 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 rejected422 { code: "validation_failed" }. The write is all-or-nothing: a departed (or never-a-member)userUlidanywhere in the array rejects the entire request with404 not_foundand adds nobody, and a request that would add zero new memberships (every listed user already a member) returns409 { code: "conflict" }— matching the existing single-member behavior at cardinality one. Repeated ULIDs are silently deduplicated. Each newly added member still emits its ownuser_group.member_addedevent; 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/groupsaccepts an optionalprojectUlidsarray, following the same convention as the existingmemberUserUlids: the group row and every seeded assignment commit or fail together, duplicates are silently deduplicated, and an unknown project ULID rejects the entire create with404 not_found. A seeded assignment is never refused for targeting a public project. Neither field is accepted onPATCH /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/groupsandDELETE /api/v1/projects/{projectId}/access/groups/{userGroupUlid}mirror the individual member allowlist’s plane and authorization rule, andGET /api/v1/groups/{userGroupUlid}/projectsreports the projects a group is assigned to, filtered to what the calling member can otherwise discover.projectCountonGET/POST /api/v1/groupsandGET /api/v1/groups/{userGroupUlid}is no longer always0— 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-usagereports 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/exportdownloads the organization’s audit log as CSV for a required, bounded window (max 366 days). Gated on organization role (owner/admin) AND theauditExportEnabledentitlement — both refusals are403 forbiddenand indistinguishable on the wire. An exact count is taken before any row is read; a window over 25,000 events is refused422 validation_failednaming 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-usagereports the organization’s counted machine-authored results for the current calendar month alongside its effectiveapiResultsPerMonthceiling. 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 reports0for 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 astatus),bulk-mark, andbulk-submit-resultnow reject a request that would push the organization’s counted machine-authored results for the current calendar month past its effectiveapiResultsPerMonthwith409 { 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 noRetry-Afterheader and reveals neither your ceiling nor your current usage; it is distinct from429 too_many_requests(a burst limiter) and from the existing409 conflicta 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/v1request authenticated with an API token or an OAuth access token now counts against your organization’s per-minute plan ceiling. Exceeding it responds429 { code: "too_many_requests" }with aRetry-Afterheader 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/mcptools/callthat dispatches one or more/api/v1sub-requests counts each dispatched sub-request separately from — and in addition to — the existing/mcpburst 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/v1and 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/projectsstill returns every project, each carryinglockedAt(epoch milliseconds, ornull), 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-identical404 { 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/projectsnow rejects a request that would push the organization’s used projects past its effective project limit with409 { code: "project_limit_exceeded" }, distinct from the409 { 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}/invitationsnow returns409 { code: "conflict" }when the invited address already holds a membership in that organization, for every role includingviewer. 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-viewerrole) andPATCH /api/v1/orgs/{orgUlid}/members/{userUlid}(when promoting aviewerto a non-viewerrole) now reject a request that would push the organization’s used seats past its effective seat limit with409 { code: "seat_limit_exceeded" }. Aviewerrole 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
PATCHnow enforces the write-role gate server-side.PATCH /api/v1/test-cases/{caseUlid}(andPATCH /defects/{defectUlid}) now reject aviewer-role caller with403 forbiddenbefore any write, matching the gate already applied to custom field values and other write endpoints. Aviewer-scoped API key that previously succeeded on these two routes now receives403. -
Breaking — Runs auto-close; Reopen removed;
rerun-failedgeneralized intoclone. 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}/reopenis removed (404).POST /api/v1/projects/{projectUlid}/runs/{runUlid}/rerun-failedis renamed to.../cloneand 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 acceptsPATCH /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}/runsandPATCH /api/v1/runs/{runUlid}now acceptenvironmentId(ULID). Run responses expose a nestedenvironment: { ulid, name, slug } | nullobject andenvironmentId. The legacy free-textenvironmentstring is still accepted on create for back-compat but should not be used in new integrations.GET .../runs/environmentsis retained but deprecated; filter byenv=<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}/resultsnow returns the full marking history (multiple rows per case) withexecutedByUlid. Runs gainedstate(open/closed),description,defaultAssigneeUlid, and progress counters. -
Projects —
PATCH/DELETErestricted to owner/admin.memberandviewernow receive403 forbiddenwhen 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/PATCHon/api/v1/projects/{projectId}/test-casesand/api/v1/test-cases/{caseUlid}no longer acceptpriority,severity,status,type,layer,behavior,isFlaky,preconditions, orpostconditions. Read them from thecustomFieldValuesarray on the case response; write them viaPUT /api/v1/test-cases/{caseUlid}/custom-field-values. The matching enum values continue to be the seeded options on the corresponding system custom fields.