Skip to content
The docs are now organized by product area. Old links still work. Report a broken link

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.

EndpointsPOST /api/v1/collect (Qualflare JSON), POST /api/v1/collect/ctrf (a CTRF report), POST /api/v1/attachments/upload-url (a large file)
TokenAny project access token; Allow writes is not needed
RoleToken creator: Editor+ · project (requires the Editor role or higher in the project)
BodyJSON with Content-Type: application/json
SizeA 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)
Answer201 with the launch number, {"seq": 128}, and a Location header
RetriesSend an Idempotency-Key (below)
CLIqf <identifier> collect sends the same request (Upload results with qf collect)

Endpoints

Method and pathDoesSection
POST /api/v1/collectCreates a launch from Qualflare JSONUpload a launch
POST /api/v1/collect/ctrfCreates a launch from a CTRF reportUpload a CTRF report
POST /api/v1/attachments/upload-urlReturns a URL to upload one large file toUpload 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.

NameTypeDefaultRequiredDescription
Idempotency-Keyheader, stringNoUp 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.json

Returns 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

FieldTypeRequiredNotes
frameworkstringYes1 to 100 characters, stored to 64
platformstringYesandroid, ios, desktop, web or api
osstringYes1 to 100 characters, stored to 64
browserstringNoUp to 64 characters
branch, commitstringNoStored to 128 characters
environmentstringYesAn environment that exists in the project, 1 to 100 characters
languagestringYesA language tag Qualflare knows, such as en or en-US, up to 128 characters
milestoneintegerNoA milestone number in the project
metadataobjectNoversion, timestamp and cliName, all strings
propertiesobjectNoUp to 100 string pairs
suitesarrayYesUp to 2,000 suites
ciProviderstringNoUp to 64 characters
ciBuildNumberstringNoUp to 128 characters
ciRunUrlstringNoA URL, up to 2,048 characters
ciPrNumberintegerNo1 or more

Suite fields

FieldTypeRequiredNotes
namestringYes1 to 255 characters
categorystringNoOne 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
assertionsintegerNo0 or more
durationintegerNoNanoseconds
timestampstringNoWhen the suite ran, in RFC 3339 form
browser, osstringNoUp to 64 characters each, when they differ from the launch's
propertiesobjectNoUp to 100 string pairs
casesarrayYesUp to 5,000 cases

Case fields

FieldTypeRequiredNotes
idstringYesYour 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.
namestringYes1 to 255 characters
classNamestringNoUp to 255 characters
descriptionstringNoUp to 1,024 characters. The API accepts more, but longer text fails to save when the upload creates the case.
statusstringYespassed, failed, skipped, error, timeout, aborted or pending
durationintegerNoNanoseconds, 0 or more
retryCountintegerNoRetries before the final result
isFlakybooleanNotrue when the test passed on a retry; with 2 or more attempts, Qualflare works it out itself
attemptsarrayNoEvery attempt, the final one last
errorstringNoThe failure message and stack trace, cut to 65,536 characters
prioritystringNocritical, high, medium or low; info becomes low, and any other value is stored empty
tagsarrayNoUp to 64, 255 characters each
propertiesobjectNoUp to 100 string pairs
attachmentsarrayNoUp to 50
stepsarrayNoCut to 1,000
shardIndexintegerNoThe shard that ran the case; a negative or out-of-range value is dropped
startedAtstringNoWhen the case started, in RFC 3339 form
labelsarrayNoUp to 100
linksarrayNoUp 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

ObjectFields
Attachmentname (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)
Stepname, 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)
Attemptattempt (its number, from 1), status, duration, startedAt, attemptId, message, trace, snippet, line, stdout and stderr (lists of lines), extra
Labelname (required, up to 128 characters), value (required, up to 512)
Linktype (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.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.

NameTypeDefaultRequiredDescription
frameworkquery, stringNoThe test framework; else the report's results.tool.name, else ctrf
platformquery, stringNoandroid, ios, desktop, web or api; else web when a test names a browser, otherwise api. Another value is ignored
osquery, stringNoThe operating system; else the report's osPlatform and osRelease, else unknown
browserquery, stringNoThe browser; else the one every test that names a browser agrees on
environmentquery, stringNoAn environment in the project, used as given; else the report's testEnvironment, else development
languagequery, stringNoA language tag such as en-US; en when it is missing or not a valid tag
branchquery, stringNoThe branch; else the report's environment.branchName
commitquery, stringNoThe commit; else the report's environment.commit
ciProviderquery, stringNoThe CI system; else read from the host of the report's buildUrl
ciBuildNumberquery, stringNoThe CI build number; else the report's buildNumber
ciRunUrlquery, stringNoAn http or https link to the CI run; else the report's buildUrl
ciPrNumberquery, integerNoThe pull request number, 1 or more
milestonequery, integerNoA milestone number in the project
Idempotency-Keyheader, stringNoUp 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.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.

{
  "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)

  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.

    NameTypeDefaultRequiredDescription
    filenamebody, stringYesThe file's name, up to 255 characters, with no path; its extension must match mimeType
    mimeTypebody, stringYesvideo/mp4, video/webm, video/quicktime, application/zip, image/png, image/jpeg or image/gif
    fileSizebody, integerYesThe 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: storageKey and uploadUrl.

    { "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:

    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:

    { "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

StatusCode and messageFix
400environment.not_found, environment "<uid>" does not exist in this projectCreate the environment, or send one that exists
400milestone.not_found, milestone #<n> does not exist in this projectSend a milestone number from the project
400common.resource_not_found, language "<uid>" is not supportedSend a language tag such as en-US
400common.validation_failed, some fields are invalid, with fieldsFix each field that fields names
400common.invalid_input, Content-Type must be application/jsonSend Content-Type: application/json
400common.invalid_input, CTRF report contains no tests or CTRF report contains no usable testsSend a report with tests in results.tests
400common.validation_failed, file extension must be .mp4 for video/mp4 files, and the same for each typeGive the file the extension of its mimeType
402quota.exceeded, Launches quota exceeded (<used>/<limit>)See What happens at a limit
403authz.project_access, no project accessUse a token whose creator is an Editor or higher in the project
409common.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)
413common.invalid_input, Request Entity Too LargeMake 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).