Upload results with the API
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.
Last updated
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; Allow writes is not needed |
| Role | Token creator: Editor+ · project (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) |
| Answer | 201 with the launch number, {"seq": 128}, and a Location header |
| Retries | Send an Idempotency-Key (below) |
| CLI | qf <identifier> collect sends the same request (Upload results with qf collect) |
Endpoints
| Method and path | Does | Section |
|---|---|---|
POST /api/v1/collect | Creates a launch from Qualflare JSON | Upload a launch |
POST /api/v1/collect/ctrf | Creates a launch from a CTRF report | Upload a CTRF report |
POST /api/v1/attachments/upload-url | Returns a URL to upload one large file to | Upload a large attachment |
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 |
curl -sS -X POST "https://api.qualflare.com/api/v1/collect" \
-H "Authorization: Bearer $QF_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @launch.jsonReturns 201: The launch number as {"seq": <n>}, and Location: /api/v1/launch/<n>.
{ "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.
Request body
This body names the run, the environment and one suite with two cases. duration is in nanoseconds.
{
"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 |
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 |
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 |
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), 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.
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.
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.jsonUpload 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 |
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.jsonReturns 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.
{
"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 is cut to them without a warning.
For how statuses, suites and environments map, see Send CTRF reports.
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)
-
Ask for an upload URL:
POST
/api/v1/attachments/upload-urlNeeds a project access token whose creator has the Editor role or higher in the project.
Name Type Default Required Description filenamebody, stringYes The file's name, up to 255 characters, with no path; its extension must match mimeTypemimeTypebody, stringYes video/mp4,video/webm,video/quicktime,application/zip,image/png,image/jpegorimage/giffileSizebody, integerYes The file's size, 1 to 52,428,800 bytes 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:storageKeyanduploadUrl.{ "storageKey": "case-run-attachments/<project>/<file>.mp4", "uploadUrl": "https://…" } -
PUTthe file's bytes touploadUrlwithin 5 minutes, withContent-Typeset to the samemimeTypeand noAuthorizationheader:curl -sS -X PUT "$UPLOAD_URL" -H "Content-Type: video/mp4" \ --data-binary @checkout.mp4 -
In the case's
attachments, reference the file bystorageKey, with itsfileSizein bytes:{ "name": "checkout.mp4", "mimeType": "video/mp4", "storageKey": "<storageKey>", "fileSize": 1048576 }
Uploaded files are kept as Data retention describes.
What happens after an upload
Qualflare saves the launch, then, in the background, sends the launch.completed webhook 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 and starts the 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 |
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.
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). MCP has no upload tool (MCP server reference).
Related
- Guides: Send CTRF reports · Upload screenshots, videos and traces
- Reference: Launches · Rate limits and request sizes
- CLI/API: Qualflare API · Access Tokens
Access Tokens
Create, use and revoke Qualflare project access tokens (qfp_), the API keys of the qf CLI, scripts and MCP agents. New tokens expire after at most 90 days.
Suites API
List and read the test suites of a Qualflare project over the REST API, analyze a suite's coverage and save its score, with every parameter and field.