# Audit events API

> List organization audit events with cursor pagination.

Probara exposes two read-only audit listing endpoints:

| Endpoint                                  | Scope               | Minimum role                                |
| ----------------------------------------- | ------------------- | ------------------------------------------- |
| `GET /api/v1/orgs/{orgUlid}/audit-events` | Entire organization | Owner or admin                              |
| `GET /api/v1/runs/{runUlid}/audit-events` | Single test run     | Any member who can read the run (`viewer`+) |

Both return the same `AuditEventOut` envelope with cursor pagination. Events are ordered by ULID descending (newest first). Events older than the organization plan's history retention window are deleted and no longer listed.

## Organization-wide listing

`GET /api/v1/orgs/{orgUlid}/audit-events` returns the append-only audit log for an organization.

## Authorization

- **Session**: sign in in the browser and send the session cookie plus `X-Organization-Id` when calling from custom scripts.
- **Bearer token**: organization-scoped API token with owner or admin membership on that org.

Members and viewers receive `403 Forbidden`.

## Query parameters

| Parameter | Type    | Description                                                               |
| --------- | ------- | ------------------------------------------------------------------------- |
| `limit`   | integer | Page size. Default `50`. Values above `100` are silently capped to `100`. |
| `cursor`  | ULID    | When set, returns events with `ulid < cursor` (older page).               |

## Response shape

```json
{
  "events": [
    {
      "ulid": "01J…",
      "action": "project.created",
      "actor": {
        "kind": "user",
        "ulid": "01J…",
        "email": "alice@example.com",
        "displayName": null,
        "viaApiToken": false
      },
      "entityType": "project",
      "entityUlid": "01J…",
      "entityLabel": "Acme Web",
      "runUlid": null,
      "metadata": {},
      "createdAt": 1700000000000
    }
  ],
  "nextCursor": "01J…"
}
```

`nextCursor` is the ULID of the last event in `events` when another page exists; otherwise `null`.

`runUlid` is present on run-scoped events (and on org-wide listings when the parent run still exists). Non-run events omit the field or set it to `null`.

## Run-scoped listing

`GET /api/v1/runs/{runUlid}/audit-events` returns only events whose `run_id` matches the resolved run inside the active organization. Use the same session cookie or bearer token as other run APIs; send `X-Organization-Id` for browser sessions.

Authorization matches `GET /api/v1/runs/{runUlid}` — any organization member with access to the run may list its history. There is **no** owner/admin gate on this route.

Query parameters and the response envelope are identical to the organization-wide endpoint (`limit`, `cursor`, `events`, `nextCursor`). A `404` means the run ULID does not exist in the active organization.

Example:

```bash
export PROBARA_API_TOKEN="probara_…"
export RUN_ULID="01J…"

curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/runs/${RUN_ULID}/audit-events?limit=50"
```

### Actor variants

```json
{
  "kind": "user",
  "ulid": "01J…",
  "email": "alice@example.com",
  "displayName": "Alice Anderson",
  "viaApiToken": true
}
```

An action taken with a personal API token is attributed to the token's owner: the actor is `kind: "user"` with `viaApiToken: true` (`false` for an action taken in the app). `kind: "api_token"` is an organization [app token](https://docs.probara.net/en/developers/authentication/#app-tokens), which belongs to no person.

`app` names its Integrations app — `"junit"` for the **JUnit XML** app, `"playwright"` for the **Playwright** app, `"jest"` for the **Jest** app, `"cypress"` for the **Cypress** app, `"vitest"` for the **Vitest** app, `"cucumber"` for the **Cucumber** app — and is `null` only when the token's record is gone:

```json
{ "kind": "api_token", "ulid": "01J…", "name": "nightly-ci", "app": "junit" }
```

An action your identity provider takes through [SCIM provisioning](https://docs.probara.net/en/guides/scim-provisioning/) is attributed to the SCIM token it used, by its ULID and name:

```json
{ "kind": "scim", "ulid": "01J…", "name": "Okta" }
```

```json
{ "kind": "system" }
```

When a user or token was deleted, reference fields are `null` but the event remains in the list:

```json
{ "kind": "user", "ulid": null, "email": null, "displayName": null }
```

## Example

```bash
export PROBARA_API_TOKEN="probara_…"
export ORG_ULID="01J…"

curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/orgs/${ORG_ULID}/audit-events?limit=50"
```

Next page:

```bash
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/orgs/${ORG_ULID}/audit-events?cursor=${NEXT_CURSOR}"
```

## Metadata and structured diffs

`metadata` is an object that may include shared **diff buckets** (documented in the OpenAPI components `AuditEventDiff`, `AuditAssigneeChange`, and `AuditRunAggregates`) plus action-specific snapshot keys. Integrators SHOULD treat unknown keys as forward-compatible.

Common diff buckets:

| Key              | Used by                                                                                                                                                                   | Shape                                                                                                                                                                                                                                                                           |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `changes`        | `*.updated`, `run.case_marked`, `run.case_step_marked`, `run.case_retried`, `run.case_step_actual_result_set`, `run.result_attachment_committed` (when text also changed) | `AuditFieldChange[]` — scalar `from` / `to` with optional `fromLabel` / `toLabel` for reference fields                                                                                                                                                                          |
| `assigneeChange` | `run.case_assigned`                                                                                                                                                       | `{ from: { ulid, name } \| null, to: { ulid, name } \| null, via: 'open' \| 'patch' \| 'reconcile' }`                                                                                                                                                                           |
| `bulkCases`      | `run.cases_bulk_*`                                                                                                                                                        | `{ cases: AuditBulkCaseEntry[], omittedCount?, summary: { affected, unchanged, statusFrom?, statusTo?, assigneeFrom?, assigneeTo? } }` — up to 20 per-case entries at emission; remainder in `omittedCount`                                                                     |
| `runAggregates`  | `run.closed`, `run.aborted`                                                                                                                                               | `{ countPassed, countFailed, countBlocked, countSkipped, countUntested, totalDurationMs, projectedStatus: 'passed' \| 'failed' \| 'aborted' }` — `run.aborted` always reports `projectedStatus: 'aborted'`, even when the case counts alone would read as `'passed'`/`'failed'` |

String snapshots longer than 240 graphemes are clipped at emission time; affected objects set `truncated: true` per the shared audit convention.

### `entity.label` formats for runs

| `entityType`         | Canonical label                                                                                           |
| -------------------- | --------------------------------------------------------------------------------------------------------- |
| `test_run`           | Run name at emission time                                                                                 |
| `test_run_case`      | `${caseDisplayId} · ${titleSnapshot}` — `caseDisplayId` is `${projectCode}-${caseNumber}` (e.g. `ACME-1`) |
| `test_run_case_step` | ``${caseDisplayId} · ${titleSnapshot} — ${stepActionSnapshot ?? `step ${stepPosition}`}``                 |

List responses may add link metadata (`runUlid`, `projectCode`, `caseDisplayId`, `titleSnapshot`, `runName`) when the stored row omitted them; stored snapshots always win.

### `run.*` metadata by action

| Action                            | Notable `metadata` keys                                                                                                                                              |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `run.created`                     | `caseCount`, optional `environment`, `defaultAssignee`, `milestone`                                                                                                  |
| `run.updated`                     | `changes` (name, description, defaultAssigneeUlid, environment, milestoneUlid)                                                                                       |
| `run.closed`                      | `runAggregates` (replaces legacy flat `status`)                                                                                                                      |
| `run.aborted`                     | `runAggregates` (same shape as `run.closed`; `projectedStatus` is always `'aborted'`)                                                                                |
| `run.deleted`                     | `runAggregates`, `wasState` (`open` \| `closed`), `closedAt` (null when still open)                                                                                  |
| `run.cloned`                      | `caseCount`, `sourceRun`, `cloneAssignees`, `statusFilter` (null when every case was cloned)                                                                         |
| `run.case_added`                  | `added`, `cases` (first 20), optional `omittedCount`                                                                                                                 |
| `run.case_marked`                 | `caseDisplayId`, `titleSnapshot`, `resultUlid`, `changes` (status), optional `elapsedMs`                                                                             |
| `run.case_step_marked`            | `caseDisplayId`, `titleSnapshot`, `runCaseUlid`, `stepUlid`, `stepPosition`, `stepActionSnapshot`, `changes` (status), `caseStatus`                                  |
| `run.case_assigned`               | `caseDisplayId`, `titleSnapshot`, `assigneeChange`                                                                                                                   |
| `run.case_retried`                | `caseDisplayId`, `titleSnapshot`, `changes` (status → `untested`), optional `previousElapsedMs`                                                                      |
| `run.case_removed`                | `caseDisplayId`, `titleSnapshot`, `statusAtRemoval`                                                                                                                  |
| `run.case_version_applied`        | `caseDisplayId`, `titleSnapshot` (the new title), `fromRevision`, `toRevision`                                                                                       |
| `run.cases_bulk_marked`           | `bulkCases` (per-case `fromStatus` / `toStatus`; homogeneous `summary.statusFrom` / `statusTo` when applicable)                                                      |
| `run.cases_bulk_assigned`         | `bulkCases` (per-case `fromAssignee` / `toAssignee`; homogeneous assignee summary when applicable)                                                                   |
| `run.cases_bulk_retried`          | `bulkCases` (per-case retry transitions; `summary.unchanged` for cases already untested)                                                                             |
| `run.cases_bulk_removed`          | `bulkCases` (per-case `statusAtRemoval`)                                                                                                                             |
| `run.cases_bulk_version_applied`  | `bulkCases` (per-case `fromRevision` / `toRevision`; `summary.affected` updated, `summary.unchanged` outdated cases skipped)                                         |
| `run.case_step_actual_result_set` | `caseDisplayId`, `titleSnapshot`, `runCaseUlid`, `stepUlid`, `stepPosition`, `stepActionSnapshot`, `changes` (`actual_result` from → to)                             |
| `run.result_attachment_committed` | `caseDisplayId`, `titleSnapshot`, `runCaseUlid`, `stepUlid`, `stepPosition`, `attachments`; optional `changes` when `actual_result` text changed in the same request |
| `run.result_attachment_deleted`   | Same shape as `run.result_attachment_committed`, without `runCaseUlid`                                                                                               |

`run.reopened` and `run.reran_failed` remain valid values of the `action` enum so historical rows keep parsing, but neither is emitted anymore — Reopen was removed and `rerun-failed` was generalized into `run.cloned` (`POST .../runs/{runUlid}/clone`).

Example — close scorecard:

```json
{
  "action": "run.closed",
  "entityType": "test_run",
  "entityLabel": "Sprint 24",
  "metadata": {
    "runAggregates": {
      "countPassed": 7,
      "countFailed": 2,
      "countBlocked": 1,
      "countSkipped": 0,
      "countUntested": 0,
      "totalDurationMs": 935000,
      "projectedStatus": "failed"
    }
  }
}
```

Example — case reassignment:

```json
{
  "action": "run.case_assigned",
  "entityType": "test_run_case",
  "entityLabel": "ACME-1 · Login flow",
  "metadata": {
    "caseDisplayId": "ACME-1",
    "titleSnapshot": "Login flow",
    "assigneeChange": {
      "from": { "ulid": "01J…", "name": "María López" },
      "to": { "ulid": "01J…", "name": "Pedro García" },
      "via": "patch"
    }
  }
}
```

## Run lifecycle mutations (abort, clone, and delete)

These endpoints mutate runs and emit the audit actions documented above. They are **not** on the audit listing routes themselves.

### `POST /api/v1/runs/{runUlid}/abort`

- **Role**: member, admin, or owner (`viewer` → `403`)
- **Body**: none
- **Response**: `200` with the updated `RunResponse` (`state: "closed"`, `abortedAt` set to the abort time)
- **Errors**: `409` when the run is not currently open (already closed or already aborted)
- **Audit**: `run.aborted` with `metadata.runAggregates` (`projectedStatus` always `'aborted'`)
- Untested cases are left untested — abort records no case results. This is terminal: an aborted run stays frozen (case mutations return `409`); there is no reopen. Use `.../clone` (below) to start a fresh run from it.

### `POST /api/v1/projects/{projectId}/runs/{runUlid}/clone`

"Run again": generalizes the former `rerun-failed` endpoint (removed).

- **Role**: member, admin, or owner (`viewer` → `403`)
- **Body**: `{ "title": "string, required, max 200", "cloneAssignees"?: boolean, "statusFilter"?: TestOutcomeStatus[] }` — `statusFilter` selects which source cases carry over; omitted or empty means every case.
- **Response**: `201` with the new `RunResponse` (`state: "open"`)
- **Errors**: `422` when `title` is empty or `statusFilter` contains an unknown status
- **Audit**: `run.cloned` with `metadata.caseCount`, `metadata.sourceRun`, `metadata.cloneAssignees`, `metadata.statusFilter`
- Works on any closed source run — completed or aborted. When `cloneAssignees` is `true`, each carried-over case keeps the source case's assignee; otherwise every case in the new run starts unassigned.

### `DELETE /api/v1/runs/{runUlid}`

- **Role**: admin or owner only (`member` / `viewer` → `403`)
- **Response**: `204 No Content`
- **Audit**: `run.deleted` with `metadata.runAggregates`, `wasState`, and `closedAt` captured before the cascade. After delete, `run_id` on audit rows (including this one) is set to `null` by FK cascade; the event remains visible in org-wide listings.

## Mutations

Audit **listing** endpoints are read-only. Use the run routes above (and other product APIs) to produce new audit rows.

## Comment actions

These actions are emitted for threaded comments on entity pages, including replies (`parentUlid` set) and pin/unpin. The `commented`, `comment_updated`, and `comment_deleted` actions include `metadata.commentUlid` and `metadata.excerpt` (first 240 characters of the comment body); `comment_pinned` and `comment_unpinned` include only `metadata.commentUlid` (pinning carries no body excerpt).

| Action                       | Emitted when                                                      | Entity type |
| ---------------------------- | ----------------------------------------------------------------- | ----------- |
| `defect.commented`           | A comment or reply is added to a defect                           | `defect`    |
| `defect.comment_updated`     | A comment on a defect is edited                                   | `defect`    |
| `defect.comment_deleted`     | A comment on a defect is deleted (once per cascaded reply too)    | `defect`    |
| `defect.comment_pinned`      | A top-level comment on a defect is pinned                         | `defect`    |
| `defect.comment_unpinned`    | A top-level comment on a defect is unpinned                       | `defect`    |
| `test_case.commented`        | A comment or reply is added to a test case                        | `test_case` |
| `test_case.comment_updated`  | A comment on a test case is edited                                | `test_case` |
| `test_case.comment_deleted`  | A comment on a test case is deleted (once per cascaded reply too) | `test_case` |
| `test_case.comment_pinned`   | A top-level comment on a test case is pinned                      | `test_case` |
| `test_case.comment_unpinned` | A top-level comment on a test case is unpinned                    | `test_case` |

Example:

```json
{
  "action": "test_case.commented",
  "entityType": "test_case",
  "entityLabel": "TC-1 Login with valid credentials",
  "metadata": {
    "commentUlid": "01J…",
    "excerpt": "Reproduced on staging with fresh session."
  }
}
```

## Token management

Create and revoke API tokens under [API authentication](https://docs.probara.net/en/developers/authentication/) (`/user/api-tokens` in the product UI).
