# Result attachments

> Attach screenshots, traces, and logs to a recorded test result.

A result (one attempt of a run case) can carry up to **20 files** of almost any type: screenshots, Playwright traces, videos, logs. Upload them in two steps: **stage** the files, then **commit** the result's attachment list. An attachment belongs to the whole result or, with `stepIndex`, to one of the steps a CI report sent with it ([Attach to a reported step](#attach-to-a-reported-step)); for the images a tester adds to a case step while executing, see [Run result attachments](https://docs.probara.net/en/developers/api/run-result-attachments/).

Access requires `member`+ on the run's project; a `viewer` receives `403 forbidden`. Writes follow the same closed-run rule as the other result writes: allowed on an `open` run, and on a `closed`, non-aborted run while the project's `allowResultsInClosedRuns` [run setting](https://docs.probara.net/en/developers/api/project-run-settings/) resolves to `true` (the default). An `aborted` run always refuses with `409 conflict`. When the project locks results to the assignee (`assigneeResultLock`), a caller who is not the assigned tester receives `403 run_case_assignee_locked`; API tokens are never exempt.

## Flow

1. **Get the result.** A CI report answers each matched entry with its `resultUlid` (`results[i].resultUlid` of `POST /api/v1/projects/{projectId}/reports`). `GET /api/v1/runs/{runUlid}/results` lists every result of a run.
2. **Stage** — `POST /api/v1/runs/{runUlid}/results/{resultUlid}/attachments:stage` with `multipart/form-data` and one or more `file` parts. Returns one staged ref per file. Nothing is attached yet and no storage is charged.
3. **Commit** — `PATCH /api/v1/runs/{runUlid}/results/{resultUlid}/attachments` with the result's complete list, each item carrying a `position`. Returns the committed list.
4. **Read** — `GET /api/v1/runs/{runUlid}/results/{resultUlid}` returns `resultAttachments`, ordered by `position`: the committed files, and the images of the result's texts (see [Images in the text](#images-in-the-text)).

A staged file that is never committed expires on its own (about 48 hours). Retrying a stage request is therefore safe; stage requests ignore the [`Idempotency-Key`](https://docs.probara.net/en/developers/api/idempotency/) header. The commit and the delete accept it.

## Stage (multipart)

Keep the whole request body at most 64 MiB (`ATTACHMENT_UPLOAD_REQUEST_RECOMMENDED_MAX_BYTES`) and split larger batches across requests. Cloudflare refuses a request body over 100 MB with `413` before it reaches the API, whatever the per-file and per-request limits allow; that `413` does not carry the API error envelope.

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -F "file=@screenshot.png" \
  -F "file=@trace.zip" \
  "https://app.probara.net/api/v1/runs/$RUN/results/$RESULT/attachments:stage"
```

```json
{
  "attachments": [
    {
      "ulid": "01J...ATT1",
      "objectKey": "staging/01J...ORG/01J...UPLOAD1.webp",
      "thumbKey": "staging/01J...ORG/01J...UPLOAD1_thumb.webp",
      "mime": "image/webp",
      "byteSize": 8420,
      "originalFilename": "screenshot.png",
      "disposition": "inline",
      "width": 1280,
      "height": 720
    },
    {
      "ulid": "01J...ATT2",
      "objectKey": "staging/01J...ORG/01J...UPLOAD2.zip",
      "thumbKey": null,
      "mime": "application/zip",
      "byteSize": 1048576,
      "originalFilename": "trace.zip",
      "disposition": "attachment",
      "width": null,
      "height": null
    }
  ]
}
```

Limits:

- Up to **20** `file` parts per request. Empty files are refused.
- Any type except executables and scripts (`application/x-msdownload`, `application/x-msdos-program`, `application/x-sh`, `application/x-bat`, `application/x-msi`, `application/x-executable`, `application/vnd.microsoft.portable-executable`), checked against each part's declared content type.
- PNG, JPEG, and WebP images (by declared content type) are converted to WebP with a thumbnail and come back with `disposition: "inline"`. They are limited to **10 MiB** and **8192 px** per side.
- Every other file is stored as sent, up to **32 MiB**, with `disposition: "attachment"`.

The first invalid part refuses the **whole request** with `422 validation_failed`: no refs come back, not even for the parts before it (those expire like any other unreferenced staged file). The error `message` is `empty_file`, `denied_type`, or `file_too_large`, which also covers an image over the pixel limit or whose header cannot be read. Validate files before uploading, or retry the remaining files one per request so one bad file does not hold back the others.

### Before the result exists

A client that uploads evidence before it records the result can stage against the run case instead: `POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/result-attachments:stage`. It takes the same body, has the same limits, and returns the same refs. It refuses any closed run with `409 conflict`, whatever the settings. Commit the refs to the result once it exists.

## Commit (replace the list)

The body is the result's **complete** attachment list. Committed files left out are deleted, so resend each existing file as `{ ulid, position }` to keep it. A new item is a staged ref plus its `position`.

```json
{
  "attachments": [
    { "ulid": "01J...KEEP", "position": 0 },
    {
      "ulid": "01J...ATT2",
      "position": 1,
      "objectKey": "staging/01J...ORG/01J...UPLOAD2.zip",
      "mime": "application/zip",
      "byteSize": 1048576,
      "originalFilename": "trace.zip",
      "disposition": "attachment",
      "thumbKey": null,
      "width": null,
      "height": null
    }
  ]
}
```

- A new item needs at least `ulid`, `position`, `objectKey`, and `originalFilename`; sending the staged ref unchanged plus `position` is simplest.
- A result holds at most **20** attachments. The commit is all-or-nothing: a refused commit changes nothing.
- `{ "attachments": [] }` removes every attachment of the result.
- An item may carry `stepIndex` to attach the file to a reported step; see below.
- An [app token](https://docs.probara.net/en/developers/authentication/#app-tokens) may only add: a body that leaves out an attachment the result already has answers `403 forbidden` and changes nothing. An app token can neither stage against the run case nor delete an attachment.
- Committed bytes count toward the organization storage quota (category `result_attachment`); staged bytes do not. A commit whose growth would exceed the quota answers `413 storage_quota_exceeded`.

### Attach to a reported step

A result recorded by a [CI report](https://docs.probara.net/en/developers/api-reference/#reports) can carry the steps the
execution ran (the entry's `steps`). To attach a file to one of them, give the item `stepIndex`: the
step's index in a depth-first, pre-order walk of the steps as they were sent, from 0 — the step's
`position` in `resultSteps` on the [attempt snapshot](https://docs.probara.net/en/developers/api/run-result-snapshots/).

```json
{
  "attachments": [
    { "ulid": "01J...KEEP", "position": 0 },
    {
      "ulid": "01J...SHOT",
      "position": 1,
      "stepIndex": 3,
      "objectKey": "staging/01J...ORG/01J...UPLOAD3.webp",
      "originalFilename": "pay.png"
    }
  ]
}
```

| `stepIndex` on the item | New item               | Existing attachment      |
| ----------------------- | ---------------------- | ------------------------ |
| Omitted                 | Attached to the result | Keeps its current step   |
| `null`                  | Attached to the result | Moves back to the result |
| A number                | Attached to that step  | Moves to that step       |

- The index must name a step of **this** result. Otherwise the whole commit answers
  `422 validation_failed` and nothing is written.
- Step attachments count toward the same limit of **20** files per result.
- The commit response, `resultAttachments` on the attempt snapshot and the run case detail return
  each attachment's `stepIndex` (`null` for a result-level file). Resend it as is when you commit
  the list again, or omit it to keep it.

## Delete one attachment

```bash
curl -sS -X DELETE \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/runs/$RUN/results/$RESULT/attachments/$ATT"
```

`204` on success. Deleting an attachment that is already gone also answers `204`.

## Errors

| Status | Code                        | When                                                                                                                                                                                                                                                                                                   |
| ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 403    | `forbidden`                 | `viewer` role; or an app token whose commit would remove an attachment, or that calls an operation other than the result-scoped stage and commit                                                                                                                                                       |
| 403    | `run_case_assignee_locked`  | The project locks results to the assignee and the caller is not the assigned tester (result-scoped operations)                                                                                                                                                                                         |
| 403    | `project_locked`            | The project is locked                                                                                                                                                                                                                                                                                  |
| 404    | `not_found`                 | Unknown run, result, or run case, or one in another organization                                                                                                                                                                                                                                       |
| 409    | `conflict`                  | The run is aborted, or closed while `allowResultsInClosedRuns` is `false`; the run-case stage refuses every closed run                                                                                                                                                                                 |
| 409    | `staging_already_committed` | Commit only: a staged ref was already committed                                                                                                                                                                                                                                                        |
| 409    | `project_archived`          | The project is archived                                                                                                                                                                                                                                                                                |
| 413    | —                           | Stage only: the request body is over 100 MB; Cloudflare refuses it before it reaches the API, without the error envelope                                                                                                                                                                               |
| 413    | `storage_quota_exceeded`    | Commit only: the organization storage quota would be exceeded                                                                                                                                                                                                                                          |
| 415    | `unsupported_media_type`    | Commit only: the body is not `application/json`                                                                                                                                                                                                                                                        |
| 422    | `validation_failed`         | Stage: no `file` part, too many files, an empty or oversized file, an image over the pixel limit, or a denied type; one invalid part refuses the whole request. Commit: an invalid body, more than 20 attachments, an invalid or expired staged ref, or a `stepIndex` that names no step of the result |
| 429    | `too_many_requests`         | The organization's API rate limit; wait `Retry-After` seconds                                                                                                                                                                                                                                          |

## Images in the text

The `resultAttachments` list also holds the images that sit inline in the result's texts, next to its files. Each image has `kind` `image` and the fields of an upload (`ulid`, `mime`, `width`, `height`, `byteSize`, `createdAt`), plus `field`, which names the text it sits in:

- `{ "kind": "description" }`, `{ "kind": "precondition" }` or `{ "kind": "postcondition" }` for the case texts the attempt froze with its header.
- `{ "kind": "step_actual_result", "runCaseStepUlid": "01J...", "position": 2 }` for the actual result of a step.
- `{ "kind": "notes" }` for the result notes.

Files keep `kind` `file`. An image leaves the list when it leaves its text.

## Related

- [Step actual results](https://docs.probara.net/en/developers/api/run-result-attachments/) — the text and inline images of a step's result
- [Test-run settings](https://docs.probara.net/en/developers/api/project-run-settings/) — `allowResultsInClosedRuns` and `assigneeResultLock`
- [Interactive v1 reference](https://docs.probara.net/en/developers/api-reference/v1/) — full schemas
