Skip to content

Project archive API

Every project carries an archivedAt timestamp (epoch ms, or null for an active project) — a reversible lifecycle flag, not a soft-delete. {projectId} is the project code (for example ACME) everywhere below.

Archive makes a project’s content read-only while its administration stays reachable:

  • Every existing route stays fully readable: an archived project’s detail page, suites, cases, runs, plans, milestones, defects, attachments, comments and exports all keep working exactly as they do for an active project. GET /api/v1/projects/{projectId} still returns 200, with a non-null archivedAt.
  • Every write to content is refused 409 { code: "project_archived" } — see Content writes are refused while archived below.
  • It does not release a project seat. An organization at its projectLimit that archives a project is still refused a new one with 409 { code: "project_limit_exceeded" } — deleting remains the only way to free a seat.

Breaking change: the list defaults to active only

Section titled “Breaking change: the list defaults to active only”

GET /api/v1/projects now hides archived projects by default. This is a deliberate change to a shipped public contract: an integrator polling this endpoint starts receiving fewer rows once anything is archived in that organization. It is observationally inert on the day this ships, because no project can be archived until this capability lands — but it takes effect immediately once you (or anyone in your organization) archives a project.

Pass the optional status query parameter to change what the list returns:

status valueResult
absent, empty, or all-unknown tokensActive only — the new default
activeActive only (same as the default, spelled out)
archivedArchived only
active,archived (either order)Both — the full pre-existing behavior

status is a comma-separated token set, matching the existing runs/defects/milestones/cases filters on this endpoint. It composes with pagination, q, memberUlid, and every other filter, and the returned total always agrees with the applied filter (never a phantom count from an unfiltered query).

Terminal window
# Only what changed since the flip: archived projects
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/projects?status=archived"
# Everything, exactly like before this change shipped
curl -sS -H "Authorization: Bearer $PROBARA_API_TOKEN" \
"https://app.probara.net/api/v1/projects?status=active,archived"

Every other consumer of the projects list inherits this same default with zero extra work on your part: the web directory, the project switcher, and any other internal reader all resolve to active-only unless they too pass status explicitly.

POST/api/v1/projects/{projectId}/archive

No request body. Returns 200 with the updated project (see Project access for the sibling resource; this is the plain project object with its archivedAt field).

{
"ulid": "01J...PROJECT",
"name": "Acme",
"id": "ACME",
"description": null,
"avatarKey": null,
"avatarVersion": 0,
"createdAt": 1700000000000,
"updatedAt": 1700000005000,
"archivedAt": 1700000005000
}

Idempotent: archiving an already-archived project responds 200 without changing the stored timestamp — a double-click or a retried request behaves identically to a single call, and this endpoint never answers 409.

Same gate as PATCH/DELETE on a project: only an organization owner or admin may archive. A member or viewer is refused 403 forbidden, with archivedAt left unchanged. An unknown {projectId}, or one belonging to another organization, answers 404 not_found.

POST/api/v1/projects/{projectId}/unarchive

No request body. Returns 200 with the updated project, archivedAt: null. Same idempotency and authorization rules as archiving, mirrored: unarchiving a project that is not archived succeeds and leaves archivedAt as null.

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

An archived project’s content is frozen. Any write into it — creating a suite, updating a test case, starting a run, saving a run result, linking a defect, mutating a test plan, writing a custom-field value, creating a comment, uploading an attachment or an avatar, and every other project-scoped content mutation — is refused:

{
"error": {
"code": "project_archived",
"message": "project is archived"
}
}

Status 409. This applies whether the write targets the project directly (/api/v1/projects/{projectId}/...) or an entity reached by its own ULID (/api/v1/test-cases/{ulid}, /api/v1/runs/{ulid}, and so on) — the refusal is enforced where the data lives, not where the URL points, so there is no route that bypasses it. An attachment upload refused this way never reaches storage: no partial or orphaned object is left behind. This applies to every credential kind, including an organization API token.

Refusal ordering is fixed: a project that does not exist (or belongs to another organization) answers 404 before archived state is ever consulted; a caller without access to a private project answers 403; only a caller who would otherwise have been admitted reaches the 409. The archived state never leaks the existence of a project the caller cannot see.

This is a breaking change to a shipped public contract, and unlike the list-default change above it is not observationally inert — any project archived before this change now refuses writes the moment it ships.

These operations manage the project itself rather than its content, and keep succeeding while the project is archived:

OperationRoute
UnarchivePOST /api/v1/projects/{projectId}/unarchive
DeleteDELETE /api/v1/projects/{projectId}
Archive (idempotent re-archive)POST /api/v1/projects/{projectId}/archive
Update (rename/re-describe)PATCH /api/v1/projects/{projectId}
Access management (mode, membership, ownership)PATCH /api/v1/projects/{projectId}/access and its member/group sub-routes

The project’s own avatar is content, not administration, and is not exempt: POST/DELETE /api/v1/projects/{projectId}/avatar are refused 409 like any other write.

A write refused 409 project_archived under an Idempotency-Key is recorded like any other response below 500, so an identical retry within the key’s TTL replays the same recorded 409 — even after the project has since been unarchived. Send a fresh, unused key to get the post-unarchive outcome. See Idempotency for the general contract.

Hard delete (DELETE /api/v1/projects/{projectId}) is unrelated to archive and remains the only irreversible removal path. Deleting does not reclaim object storage — the project’s picture and every descendant step attachment survive the cascade. This is pre-existing, out of scope here, and tracked as its own future change.