# Upload results with the API

Source: https://docs.qualflare.com/docs/api/collect · Last updated: 2026-11-03

Upload test results to Qualflare over HTTP without qf: POST /api/v1/collect and /collect/ctrf, the JSON body, Idempotency-Key and large attachment uploads.

To upload test results to Qualflare without the qf CLI, send them as JSON to `POST https://api.qualflare.com/api/v1/collect` with a project access token; each request creates one launch and returns its number.

|               |                                                                                                                                                                                                                                                                                  |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Endpoints** | `POST /api/v1/collect` (Qualflare JSON), `POST /api/v1/collect/ctrf` (a CTRF report), `POST /api/v1/attachments/upload-url` (a large file)                                                                                                                                       |
| **Token**     | Any project [access token](https://docs.qualflare.com/docs/api/access-tokens); **Allow writes** is not needed                                                                                                                                                                    |
| **Role**      | Token creator: (requires the Editor role or higher in the project)                                                                                                                                                                                                               |
| **Body**      | JSON with `Content-Type: application/json`                                                                                                                                                                                                                                       |
| **Size**      | A request body can be up to 1 MB, except on `POST /api/v1/collect` and `POST /api/v1/collect/ctrf`, which take up to 10 MB. A larger body gets `413` with `Request Entity Too Large`. ([Rate limits and request sizes](https://docs.qualflare.com/docs/api/limits#request-size)) |
| **Answer**    | `201` with the launch number, `{"seq": 128}`, and a `Location` header                                                                                                                                                                                                            |
| **Retries**   | Send an `Idempotency-Key` ([below](https://docs.qualflare.com/docs/api/collect#idempotency-key))                                                                                                                                                                   |
| **CLI**       | `qf <identifier> collect` sends the same request ([Upload results with qf collect](https://docs.qualflare.com/docs/cli/collect))                                                                                                                                                 |

## Endpoints

| Method and path                       | Does                                      | Section                                                                                           |
| ------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `POST /api/v1/collect`                | Creates a launch from Qualflare JSON      | [Upload a launch](https://docs.qualflare.com/docs/api/collect#collect)              |
| `POST /api/v1/collect/ctrf`           | Creates a launch from a CTRF report       | [Upload a CTRF report](https://docs.qualflare.com/docs/api/collect#collect-ctrf)    |
| `POST /api/v1/attachments/upload-url` | Returns a URL to upload one large file to | [Upload a large attachment](https://docs.qualflare.com/docs/api/collect#upload-url) |

## Upload a launch

Send the whole run as one JSON body.

**POST** `/api/v1/collect`

Needs a project access token whose creator has the Editor role or higher in the project.

| Name              | Type             | Default | Required | Description                                                 |
| ----------------- | ---------------- | ------- | -------- | ----------------------------------------------------------- |
| `Idempotency-Key` | `header`, string |         | No       | Up to 255 characters; reuse it on every retry of one upload |

```bash
curl -sS -X POST "https://api.qualflare.com/api/v1/collect" \
  -H "Authorization: Bearer $QF_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @launch.json
```

Returns `201`: The launch number as `{"seq": <n>}`, and `Location: /api/v1/launch/<n>`.

```json
{ "seq": 128 }
```

One request is one launch: put every suite of the run in `suites`. Qualflare finds each suite by `name` and creates the ones it doesn't have; inside a suite it matches each case to an existing test case by `name`, and creates new ones as Active, automated cases. See [How uploads become test cases](https://docs.qualflare.com/docs/runs/automated-results).

### Request body

This body names the run, the environment and one suite with two cases. `duration` is in nanoseconds.

```json
{
  "framework": "playwright",
  "platform": "web",
  "os": "linux",
  "environment": "development",
  "language": "en-US",
  "branch": "main",
  "commit": "9fceb02",
  "suites": [
    {
      "name": "Checkout",
      "duration": 4200000000,
      "cases": [
        { "id": "checkout-pays-by-card", "name": "pays by card", "status": "passed", "duration": 1800000000 },
        { "id": "checkout-applies-coupon", "name": "applies a coupon", "status": "failed", "duration": 2400000000, "error": "Expected total 90, got 100" }
      ]
    }
  ]
}
```

To see the body qf would send for your own reports, run `qf <identifier> collect <files> --dry-run --output json`.

### Launch fields

| Field              | Type    | Required | Notes                                                                                        |
| ------------------ | ------- | -------- | -------------------------------------------------------------------------------------------- |
| `framework`        | string  | Yes      | 1 to 100 characters, stored to 64                                                            |
| `platform`         | string  | Yes      | `android`, `ios`, `desktop`, `web` or `api`                                                  |
| `os`               | string  | Yes      | 1 to 100 characters, stored to 64                                                            |
| `browser`          | string  | No       | Up to 64 characters                                                                          |
| `branch`, `commit` | string  | No       | Stored to 128 characters                                                                     |
| `environment`      | string  | Yes      | An environment that exists in the project, 1 to 100 characters                               |
| `language`         | string  | Yes      | A language tag Qualflare knows, such as `en` or `en-US`, up to 128 characters                |
| `milestone`        | integer | No       | A milestone number in the project                                                            |
| `metadata`         | object  | No       | `version`, `timestamp` and `cliName`, all strings                                            |
| `properties`       | object  | No       | Up to 100 string pairs                                                                       |
| `suites`           | array   | Yes      | Up to 2,000 [suites](https://docs.qualflare.com/docs/api/collect#suite-fields) |
| `ciProvider`       | string  | No       | Up to 64 characters                                                                          |
| `ciBuildNumber`    | string  | No       | Up to 128 characters                                                                         |
| `ciRunUrl`         | string  | No       | A URL, up to 2,048 characters                                                                |
| `ciPrNumber`       | integer | No       | 1 or more                                                                                    |

### Suite fields

| Field           | Type    | Required | Notes                                                                                                                                                                                                                                                                                                                                            |
| --------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `name`          | string  | Yes      | 1 to 255 characters                                                                                                                                                                                                                                                                                                                              |
| `category`      | string  | No       | One of `unit`, `bdd`, `e2e`, `api`, `security`, `generic`, `junit`, `python`, `golang`, `jest`, `vitest`, `mocha`, `rspec`, `phpunit`, `testng`, `cucumber`, `karate`, `playwright`, `cypress`, `selenium`, `testcafe`, `maestro`, `xctest`, `espresso`, `webdriverio`, `appium`, `flutter`, `newman`, `k6`, `zap`, `trivy`, `snyk`, `sonarqube` |
| `assertions`    | integer | No       | 0 or more                                                                                                                                                                                                                                                                                                                                        |
| `duration`      | integer | No       | Nanoseconds                                                                                                                                                                                                                                                                                                                                      |
| `timestamp`     | string  | No       | When the suite ran, in RFC 3339 form                                                                                                                                                                                                                                                                                                             |
| `browser`, `os` | string  | No       | Up to 64 characters each, when they differ from the launch's                                                                                                                                                                                                                                                                                     |
| `properties`    | object  | No       | Up to 100 string pairs                                                                                                                                                                                                                                                                                                                           |
| `cases`         | array   | Yes      | Up to 5,000 [cases](https://docs.qualflare.com/docs/api/collect#case-fields)                                                                                                                                                                                                                                                       |

### Case fields

| Field         | Type    | Required | Notes                                                                                                                                                        |
| ------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id`          | string  | Yes      | Your ID for the test, unique within its suite. Up to 512 characters. The API accepts more, but a longer `id` fails to save when the upload creates the case. |
| `name`        | string  | Yes      | 1 to 255 characters                                                                                                                                          |
| `className`   | string  | No       | Up to 255 characters                                                                                                                                         |
| `description` | string  | No       | Up to 1,024 characters. The API accepts more, but longer text fails to save when the upload creates the case.                                                |
| `status`      | string  | Yes      | `passed`, `failed`, `skipped`, `error`, `timeout`, `aborted` or `pending`                                                                                    |
| `duration`    | integer | No       | Nanoseconds, 0 or more                                                                                                                                       |
| `retryCount`  | integer | No       | Retries before the final result                                                                                                                              |
| `isFlaky`     | boolean | No       | `true` when the test passed on a retry; with 2 or more `attempts`, Qualflare works it out itself                                                             |
| `attempts`    | array   | No       | Every attempt, the final one last                                                                                                                            |
| `error`       | string  | No       | The failure message and stack trace, cut to 65,536 characters                                                                                                |
| `priority`    | string  | No       | `critical`, `high`, `medium` or `low`; `info` becomes `low`, and any other value is stored empty                                                             |
| `tags`        | array   | No       | Up to 64, 255 characters each                                                                                                                                |
| `properties`  | object  | No       | Up to 100 string pairs                                                                                                                                       |
| `attachments` | array   | No       | Up to 50                                                                                                                                                     |
| `steps`       | array   | No       | Cut to 1,000                                                                                                                                                 |
| `shardIndex`  | integer | No       | The shard that ran the case; a negative or out-of-range value is dropped                                                                                     |
| `startedAt`   | string  | No       | When the case started, in RFC 3339 form                                                                                                                      |
| `labels`      | array   | No       | Up to 100                                                                                                                                                    |
| `links`       | array   | No       | Up to 20                                                                                                                                                     |

> **Warning:** On a case the upload creates, a `description` over 1,024 characters, an `id` over 512 characters, or an `id` another case in the suite already has fails the save, and the whole upload with it: no launch is saved. Renaming a test while keeping its `id` does this, because Qualflare matches cases by `name` and creates a new case for the new name. Keep descriptions to 1,024 characters and IDs to 512, and give each test in a suite its own `id`, and a renamed test a new one.

### Attachments, steps, attempts, labels and links

| Object     | Fields                                                                                                                                                                                                                                                                                                                                                |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Attachment | `name` (required, up to 255 characters), `path` (1,024), `mimeType` (255, stored to 128), `content` (base64, up to 2,097,152 characters, about 1.5 MB), `storageKey` (1,024, from an [upload URL](https://docs.qualflare.com/docs/api/collect#upload-url)), `fileSize` (bytes, with `storageKey`), `stepIndex` (the step it belongs to) |
| Step       | `name`, `keyword` (stored to 64), `status`, `duration`, `error`, `location`, `parentIndex` (its parent step's index), `parameters` (`name`, `value`, `masked`; `masked` only hides the value on screen, which is stored as sent)                                                                                                                      |
| Attempt    | `attempt` (its number, from 1), `status`, `duration`, `startedAt`, `attemptId`, `message`, `trace`, `snippet`, `line`, `stdout` and `stderr` (lists of lines), `extra`                                                                                                                                                                                |
| Label      | `name` (required, up to 128 characters), `value` (required, up to 512)                                                                                                                                                                                                                                                                                |
| Link       | `type` (required: `issue`, `tms` or `custom`), `name` (255), `url` (required, a URL up to 2,048 characters)                                                                                                                                                                                                                                           |

List every attempt of a case in `attempts`, the final one last: fewer than 2 save nothing, and more than 50 keep the first 49 and the last.

### Shortened, not refused

Some fields over their limit are shortened and the rest of the upload is saved; the limits that refuse an upload and the ones that cut it are in [Results in one upload](https://docs.qualflare.com/docs/api/limits#upload-caps).

## Retry safely with Idempotency-Key

Send an `Idempotency-Key` header with a value you generate once per upload, such as a UUID, and reuse it on every retry of that upload. A retry with the same key answers with the first launch's number and creates nothing: it isn't counted against your plan's launches again, and it sends no second webhook. qf does this for you.

```bash
KEY=$(uuidgen)
curl -sS -X POST "https://api.qualflare.com/api/v1/collect" \
  -H "Authorization: Bearer $QF_TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" --data-binary @launch.json
```

## Upload a CTRF report

Send the CTRF JSON file as the body and the launch's metadata in the query string.

**POST** `/api/v1/collect/ctrf`

Needs a project access token whose creator has the Editor role or higher in the project.

| Name              | Type             | Default | Required | Description                                                                                                                    |
| ----------------- | ---------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `framework`       | `query`, string  |         | No       | The test framework; else the report's `results.tool.name`, else `ctrf`                                                         |
| `platform`        | `query`, string  |         | No       | `android`, `ios`, `desktop`, `web` or `api`; else `web` when a test names a browser, otherwise `api`. Another value is ignored |
| `os`              | `query`, string  |         | No       | The operating system; else the report's `osPlatform` and `osRelease`, else `unknown`                                           |
| `browser`         | `query`, string  |         | No       | The browser; else the one every test that names a browser agrees on                                                            |
| `environment`     | `query`, string  |         | No       | An environment in the project, used as given; else the report's `testEnvironment`, else `development`                          |
| `language`        | `query`, string  |         | No       | A language tag such as `en-US`; `en` when it is missing or not a valid tag                                                     |
| `branch`          | `query`, string  |         | No       | The branch; else the report's `environment.branchName`                                                                         |
| `commit`          | `query`, string  |         | No       | The commit; else the report's `environment.commit`                                                                             |
| `ciProvider`      | `query`, string  |         | No       | The CI system; else read from the host of the report's `buildUrl`                                                              |
| `ciBuildNumber`   | `query`, string  |         | No       | The CI build number; else the report's `buildNumber`                                                                           |
| `ciRunUrl`        | `query`, string  |         | No       | An http or https link to the CI run; else the report's `buildUrl`                                                              |
| `ciPrNumber`      | `query`, integer |         | No       | The pull request number, 1 or more                                                                                             |
| `milestone`       | `query`, integer |         | No       | A milestone number in the project                                                                                              |
| `Idempotency-Key` | `header`, string |         | No       | Up to 255 characters; reuse it on every retry of one upload                                                                    |

```bash
curl -sS -X POST "https://api.qualflare.com/api/v1/collect/ctrf?environment=staging&branch=main" \
  -H "Authorization: Bearer $QF_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @ctrf-report.json
```

Returns `201`: The launch number and `warnings`.

When the query leaves out the platform, language, browser, milestone or a CI field, a report exported from Qualflare fills it from its own `qualflare.*` block first. The environment comes only from the query, the report's `testEnvironment` or `development`.

The answer adds `warnings`, up to 20, each with a `path` and a `reason`: an environment Qualflare fell back from, a `buildUrl` it dropped, a `summary` count that doesn't match the tests, or a screenshot it dropped or kept as a reference.

```json
{
  "seq": 129,
  "warnings": [
    { "path": "results.environment.testEnvironment", "reason": "environment \"staging-eu\" does not exist in this project; recorded against \"development\" instead" }
  ]
}
```

A report over the [upload limits](https://docs.qualflare.com/docs/api/limits#upload-caps) is cut to them without a warning.

For how statuses, suites and environments map, see [Send CTRF reports](https://docs.qualflare.com/docs/cli/ctrf#how-qualflare-reads-ctrf).

## Upload a large attachment

A file you upload through `POST /api/v1/attachments/upload-url` can be up to 50 MB: an MP4, WebM or QuickTime video, a ZIP file such as a Playwright trace, or a PNG, JPEG or GIF image. Its upload URL works for 5 minutes. ([Rate limits and request sizes](https://docs.qualflare.com/docs/api/limits#attachments))

1. Ask for an upload URL:

   **POST** `/api/v1/attachments/upload-url`

   Needs a project access token whose creator has the Editor role or higher in the project.

   | Name       | Type            | Default | Required | Description                                                                                               |
   | ---------- | --------------- | ------- | -------- | --------------------------------------------------------------------------------------------------------- |
   | `filename` | `body`, string  |         | Yes      | The file's name, up to 255 characters, with no path; its extension must match `mimeType`                  |
   | `mimeType` | `body`, string  |         | Yes      | `video/mp4`, `video/webm`, `video/quicktime`, `application/zip`, `image/png`, `image/jpeg` or `image/gif` |
   | `fileSize` | `body`, integer |         | Yes      | The file's size, 1 to 52,428,800 bytes                                                                    |

   ```bash
   curl -sS -X POST "https://api.qualflare.com/api/v1/attachments/upload-url" \
     -H "Authorization: Bearer $QF_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"filename": "checkout.mp4", "mimeType": "video/mp4", "fileSize": 1048576}'
   ```

   Returns `201`: `storageKey` and `uploadUrl`.

   ```json
   { "storageKey": "case-run-attachments/<project>/<file>.mp4", "uploadUrl": "https://…" }
   ```

2. `PUT` the file's bytes to `uploadUrl` within 5 minutes, with `Content-Type` set to the same `mimeType` and no `Authorization` header:

   ```bash
   curl -sS -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" \
     --data-binary @checkout.mp4
   ```

3. In the case's `attachments`, reference the file by `storageKey`, with its `fileSize` in bytes:

   ```json
   { "name": "checkout.mp4", "mimeType": "video/mp4", "storageKey": "<storageKey>", "fileSize": 1048576 }
   ```

Uploaded files are kept as [Data retention](https://docs.qualflare.com/docs/administration/project-settings#data-retention) describes.

## What happens after an upload

Qualflare saves the launch, then, in the background, sends the `launch.completed` [webhook](https://docs.qualflare.com/docs/integrations/webhooks#webhook-events) and your integrations' messages, adds the launch to the project's public report when it is published, and, when your plan includes them and AI is on for the project, groups its failures into [failure clusters](https://docs.qualflare.com/docs/ai/failure-clusters) and starts the [AI launch analysis](https://docs.qualflare.com/docs/ai/launch-analysis). A retry with the same `Idempotency-Key` does none of this again.

## Errors

| Status | Code and message                                                                                          | Fix                                                                                                         |
| ------ | --------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `400`  | `environment.not_found`, `environment "<uid>" does not exist in this project`                             | Create the environment, or send one that exists                                                             |
| `400`  | `milestone.not_found`, `milestone #<n> does not exist in this project`                                    | Send a milestone number from the project                                                                    |
| `400`  | `common.resource_not_found`, `language "<uid>" is not supported`                                          | Send a language tag such as `en-US`                                                                         |
| `400`  | `common.validation_failed`, `some fields are invalid`, with `fields`                                      | Fix each field that `fields` names                                                                          |
| `400`  | `common.invalid_input`, `Content-Type must be application/json`                                           | Send `Content-Type: application/json`                                                                       |
| `400`  | `common.invalid_input`, `CTRF report contains no tests` or `CTRF report contains no usable tests`         | Send a report with tests in `results.tests`                                                                 |
| `400`  | `common.validation_failed`, `file extension must be .mp4 for video/mp4 files`, and the same for each type | Give the file the extension of its `mimeType`                                                               |
| `402`  | `quota.exceeded`, `Launches quota exceeded (<used>/<limit>)`                                              | See [What happens at a limit](https://docs.qualflare.com/docs/administration/plans#what-happens-at-a-limit) |
| `403`  | `authz.project_access`, `no project access`                                                               | Use a token whose creator is an Editor or higher in the project                                             |
| `409`  | `common.conflict`, `resource already exists (cases_project_suite_uid_unique)`                             | Give each test in the suite its own `id`, and a renamed test a new one (in CTRF, its `testId`)              |
| `413`  | `common.invalid_input`, `Request Entity Too Large`                                                        | Make the body smaller                                                                                       |

Every status and code: [Errors and status codes](https://docs.qualflare.com/docs/api/errors).

## CLI and MCP equivalents

`qf <identifier> collect` builds this body from your report files and sends it with an `Idempotency-Key` ([Upload results with qf collect](https://docs.qualflare.com/docs/cli/collect)). MCP has no upload tool ([MCP server reference](https://docs.qualflare.com/docs/api/mcp-reference)).

## Related

* **Guides:** [Send CTRF reports](https://docs.qualflare.com/docs/cli/ctrf) · [Upload screenshots, videos and traces](https://docs.qualflare.com/docs/cli/artifacts)
* **Reference:** [Launches](https://docs.qualflare.com/docs/runs/launches) · [Rate limits and request sizes](https://docs.qualflare.com/docs/api/limits)
* **CLI/API:** [Qualflare API](https://docs.qualflare.com/docs/api) · [Access Tokens](https://docs.qualflare.com/docs/api/access-tokens)
