Submission API for uploading JUnit XML from CI
TestNod's Submission API is the set of HTTP endpoints your CI calls to send JUnit XML reports and create test runs. This page walks through each endpoint with worked examples, so you can wire it up by hand or understand what a CI integration is doing under the hood.
A complete build sends one or more uploads followed by a single finalize call:
upload → upload → upload → finalize
Every upload that shares a build_id attaches to the same run, and the finalize call is what moves that run out of pending. Without it, the test run never finishes processing and stays in a pending state indefinitely. The sequence is the same whether your build produces one JUnit file or twenty.
Endpoints
POST /integrations/test_runs/upload
POST /integrations/test_runs/finalize
POST /integrations/test_runs/upload_failed
Base URL: https://app.testnod.com. All three authenticate with the Project-Token header.
Upload
Content type: multipart/form-data.
Required fields
| Field | Description |
|---|---|
Project-Token (header) |
The project token. See Authentication. |
test_run[metadata][build_id] |
CI build identifier. Groups multi-file uploads into one run, and is the key the finalize call uses. |
Optional fields
| Field | Type | Notes |
|---|---|---|
test_run[metadata][branch] |
string | Branch the build ran on. Used by the baseline finder and run filters. |
test_run[metadata][commit_sha] |
string | Full commit SHA. Shown on the run page; used for cross-linking to your VCS. |
test_run[metadata][run_url] |
string | URL of the CI run that produced this report. Linked from the run page. |
tags[][value] |
string (repeated) | One or more tag values. If omitted, TestNod adds a single Default tag. |
Successful response
HTTP/1.1 201 Created
Content-Type: application/json
{
"id": 12345,
"project_id": "b7e2c1a0-9f3d-4a8b-bc1e-2d4f6a8c0e11",
"test_run_id": 42,
"upload_id": 1,
"test_run_url": "https://app.testnod.com/projects/b7e2c1a0-9f3d-4a8b-bc1e-2d4f6a8c0e11/test_runs/42",
"presigned_url": "https://..."
}
Your CI client PUTs the JUnit file directly to presigned_url. If that PUT fails, your CI client calls /integrations/test_runs/upload_failed with the test_run_id and upload_id from this response so the upload can be marked terminal.
Error responses
| Status | Body | Meaning |
|---|---|---|
404 Not Found |
{"error_message":"Project not found."} |
The Project-Token header is missing, malformed, or does not match any project. |
422 Unprocessable Content |
{"error_message":"metadata.build_id is required."} |
test_run[metadata][build_id] was missing or blank. |
422 Unprocessable Content |
{"error_message":"There was a problem creating this test run."} |
The request reached the project but TestNod could not save the run. |
409 Conflict |
{"error_message":"Conflicting concurrent upload."} |
Two concurrent uploads for the same build_id raced. Retry once. |
A 4xx response means TestNod did not record an upload. Retry the request after fixing the cause.
Sending the file to the presigned URL
The upload call registers the upload and hands back a presigned_url; the JUnit file itself is not part of that request. PUT the file straight to the returned URL, which is valid for five minutes:
curl -sS -H "Content-Type: application/xml" \
--upload-file tmp/rspec.xml "$PRESIGNED_URL"
A successful PUT returns 200 with an empty body. If it fails, report it with the upload-failed endpoint so the upload can be marked terminal.
Finalize
After uploading every file for a build, call:
curl -sS -X POST https://app.testnod.com/integrations/test_runs/finalize \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-d "build_id=$BUILD_ID"
build_id is a top-level form field, not nested under test_run[metadata]. Finalize is idempotent, so it is safe to retry on transient errors or call from more than one CI job. If every upload has reached a terminal state by the time finalize is called, the run transitions immediately; otherwise the worker transitions it when the last upload completes.
Response on success:
HTTP/1.1 200 OK
Content-Type: application/json
{ "status": "processing" }
The status is whatever state the run is in after the call: pending, processing, processed, or failed.
Error responses:
| Status | Body | Meaning |
|---|---|---|
404 Not Found |
{"error_message":"Test run not found for this build_id."} |
No run exists for (project, build_id). Confirm the upload step succeeded with the same build_id. |
422 Unprocessable Content |
{"error_message":"build_id is required."} |
The build_id parameter was missing. |
422 Unprocessable Content |
{"error_message":"No uploads received for this test run."} |
The run exists but no upload attached. Finalize will not transition an empty run. |
Reporting an upload failure from your CI client
If your CI client's PUT to presigned_url fails, call:
curl -sS -X POST https://app.testnod.com/integrations/test_runs/upload_failed \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-d "test_run_id=$TEST_RUN_ID" \
-d "upload_id=$UPLOAD_ID" \
-d "failure_message=s3 PUT returned 502"
This is for client-side failures only. Parsing errors are reported by TestNod after the upload completes, not from your CI client. Response is {"status":"failed"} with 200, or {"status": "<prior_status>"} if the upload was already terminal.
Async processing
The upload call returns immediately. The JUnit file is parsed asynchronously, and the parent run only moves out of pending once finalize has been called and every upload is terminal.
Worked example
A typical build sends one or more uploads and one finalize:
# 1. Register the upload. The response includes a presigned URL.
response=$(curl -sS -X POST https://app.testnod.com/integrations/test_runs/upload \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-F "test_run[metadata][build_id]=$CI_BUILD_ID" \
-F "test_run[metadata][branch]=$CI_BRANCH" \
-F "test_run[metadata][commit_sha]=$CI_COMMIT_SHA" \
-F "test_run[metadata][run_url]=$CI_RUN_URL" \
-F "tags[][value]=smoke")
# 2. PUT the JUnit file to the presigned URL from that response.
upload_url=$(echo "$response" | grep -o '"presigned_url":"[^"]*"' | cut -d'"' -f4)
curl -sS -H "Content-Type: application/xml" --upload-file tmp/rspec.xml "$upload_url"
# 3. When every file for this build has been uploaded:
curl -sS -X POST https://app.testnod.com/integrations/test_runs/finalize \
-H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
-d "build_id=$CI_BUILD_ID"
The CI integration pages under CI integrations wrap this same pair of calls into provider-specific syntax, including patterns for parallel and matrix builds.