cURL Guide
This guide is the plain-HTTP path: no CLI to install, no GitHub Action, no orb. Use it when your CI system doesn’t have a dedicated Gaffer guide, when you’re debugging what the CLI or Action does under the hood, or when you just want to see a report on your project without adding a dependency.
Prerequisites
Section titled “Prerequisites”- A Gaffer account with a project
- Your project token, a string starting with
gfr_followed by 64 hex characters - cURL installed (available by default on macOS and most Linux distributions)
What’s the simplest way to upload a report?
Section titled “What’s the simplest way to upload a report?”POST the file to /api/upload as multipart form data with your project token in the X-API-Key header.
curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@path/to/test-report.html"Replace YOUR_PROJECT_TOKEN with your actual project token, and update the file path to point to your test report. Don’t set Content-Type yourself: curl generates the multipart/form-data boundary header for you when you use -F, and overriding it breaks the request.
How do I attach commit and branch metadata?
Section titled “How do I attach commit and branch metadata?”Pass a tags field alongside files, as a JSON string. commitSha and branch are the two tags Gaffer uses to correlate a run with your code.
curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F 'tags={"commitSha":"abc123def456","branch":"main","test_framework":"playwright","test_suite":"e2e"}'How do I upload more than one file at once?
Section titled “How do I upload more than one file at once?”Repeat the -F "files=..." flag once per file. Gaffer accepts multiple files in the same request and parses each one it recognizes.
curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@playwright-report/index.html" \ -F "files=@playwright-report/data/report.json" \ -F "files=@playwright-report/screenshots/failed-test.png" \ -F 'tags={"commitSha":"abc123","branch":"feature/login"}'How do I pull git metadata in automatically?
Section titled “How do I pull git metadata in automatically?”Use shell command substitution inside the tags field. This works in any CI system with a git checkout, which is why it’s the fallback used later in this guide for CI systems without a dedicated integration.
curl -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@test-results/junit.xml" \ -F 'tags={"commitSha":"'"$(git rev-parse HEAD)"'","branch":"'"$(git branch --show-current)"'"}'What’s the full request shape?
Section titled “What’s the full request shape?”The request is multipart/form-data over HTTPS. This is the complete contract, not just the fields the examples above use:
| Part | Required | Details |
|---|---|---|
X-API-Key header | Yes | Your project token (gfr_ + 64 hex chars). Missing header returns 401 with “API key required. Provide X-API-Key header.” |
files field | Yes | One or more files, repeat the field per file. At least one file with the exact field name files is required. |
tags field | No | A JSON object of string keys to string values. Max 20 tags, keys max 64 characters, values max 256 characters, and the whole JSON blob capped at 64 KB. |
Filenames are sanitized server-side; a filename with path-traversal or control characters is rejected with a 400 before anything touches storage.
What does a successful response look like?
Section titled “What does a successful response look like?”A 201 with a testRun object and a files array, both derived from what you sent:
{ "testRun": { "id": "abc123xyz", "uniqueId": "1701234567890", "projectId": "proj_abc123", "commitSha": "abc123def456", "branch": "main", "tags": { "commitSha": "abc123def456", "branch": "main" }, "createdAt": "2024-01-15T10:30:00.000Z" }, "files": [ { "filename": "index.html", "size": 245678, "path": "org_123/proj_456/1701234567890/index.html", "contentType": "text/html" } ]}If you’re scripting against this, pull fields out with jq instead of parsing JSON by hand:
RESPONSE=$(curl -s -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: YOUR_PROJECT_TOKEN" \ -F "files=@report.html")
TEST_RUN_ID=$(echo "$RESPONSE" | jq -r '.testRun.id')Your project token also authenticates read requests, so you can poll a run’s processing status the same way without opening the dashboard:
curl -s "https://app.gaffer.sh/api/v1/user/projects/$PROJECT_ID/upload-sessions/$SESSION_ID" \ -H "X-API-Key: YOUR_PROJECT_TOKEN" | jq -r '.session.processingStatus'processingStatus moves through pending → processing → completed (or error).
When do I need multipart upload instead of a single POST?
Section titled “When do I need multipart upload instead of a single POST?”Above 75 MB total request size. POST /api/upload enforces a 75 MB ceiling and returns a 413 (file_too_large_for_v1) past it, well under Cloudflare’s own edge limit, so the error is a clean, actionable one instead of a raw connection failure. For files up to 5 GB (R2’s per-object ceiling), use the multipart upload (MPU) endpoints directly.
The MPU flow is three calls: create the upload, PUT each part, then complete it. The server tells you the part size, don’t invent your own:
#!/bin/bashset -euo pipefail
FILE="huge-playwright-trace.zip"FILE_SIZE=$(stat -f%z "$FILE" 2>/dev/null || stat -c%s "$FILE")
# 1. Start the multipart uploadCREATE=$(curl -s -X POST https://app.gaffer.sh/api/upload/mpu/create \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"fileName\":\"$FILE\",\"fileSize\":$FILE_SIZE,\"tags\":{\"commitSha\":\"$(git rev-parse HEAD)\",\"branch\":\"$(git branch --show-current)\"}}")
UPLOAD_ID=$(echo "$CREATE" | jq -r '.uploadId')SESSION_ID=$(echo "$CREATE" | jq -r '.uploadSessionId')PART_SIZE=$(echo "$CREATE" | jq -r '.partSize')
# 2. Split the file and PUT each part, collecting the etags it returnssplit -b "$PART_SIZE" -d -a 4 "$FILE" /tmp/gaffer-part-PARTS="[]"i=1for part in /tmp/gaffer-part-*; do ETAG=$(curl -s -X PUT "https://app.gaffer.sh/api/upload/mpu/part?uploadSessionId=$SESSION_ID&uploadId=$UPLOAD_ID&partNumber=$i" \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ --data-binary "@$part" | jq -r '.etag') PARTS=$(echo "$PARTS" | jq --argjson pn "$i" --arg et "$ETAG" '. + [{"partNumber": $pn, "etag": $et}]') i=$((i + 1))donerm -f /tmp/gaffer-part-*
# 3. Complete the upload once every part has landedcurl -s -X POST https://app.gaffer.sh/api/upload/mpu/complete \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"uploadSessionId\":\"$SESSION_ID\",\"uploadId\":\"$UPLOAD_ID\",\"parts\":$PARTS}"The server picks the part size from your declared fileSize: 10 MB parts under 500 MB, 25 MB parts up to 2 GB, and 50 MB parts up to the 5 GB ceiling. If a part upload fails partway through, call DELETE /api/upload/mpu/abort?uploadSessionId=...&uploadId=... to clean up the orphaned R2 upload rather than leaving it dangling.
What HTTP errors can the upload endpoint return?
Section titled “What HTTP errors can the upload endpoint return?”Every error response is JSON with a reason field, useful if you’re branching on it in a script rather than eyeballing logs.
| Status | reason | Cause | Fix |
|---|---|---|---|
| 400 | no_files | No files field, or a field named something else | Use -F "files=@path" with the exact field name |
| 400 | invalid_tags | tags isn’t valid JSON, or exceeds 20 keys / 64-char keys / 256-char values | Fix the JSON, trim the tag count or length |
| 400 | invalid_content_type | Body wasn’t sent as multipart/form-data | Let curl set Content-Type, don’t pass your own |
| 400 | invalid_filename | Filename contains unsafe or malformed characters | Rename the file before uploading |
| 401 | (none, plain 401) | Missing, empty, or malformed X-API-Key | Confirm the header is set and the token starts with gfr_ |
| 402 | quota_exceeded | Free tier only: the upload would push the organization over its storage quota. Pro and Team bill metered overage instead, so they never hit this | Upgrade the plan or delete old reports |
| 413 | file_too_large_for_v1 | Request over 75 MB | Use the multipart flow above, the CLI, or the GitHub Action |
| 503 | r2_error, db_error | Transient storage or database failure | Retry, see the next section first |
The multipart endpoints add their own reasons: invalid_body and invalid_query for malformed create/complete requests, invalid_part_number for out-of-order or out-of-range parts, size_mismatch if the assembled object’s size drifts more than 1% from what you declared in fileSize, and mpu_error (503) when a part upload or the final assembly fails against storage.
Is it safe to retry a failed upload?
Section titled “Is it safe to retry a failed upload?”Not blindly. POST /api/upload has no client-supplied idempotency key: every call generates a fresh run ID server-side. If curl times out waiting for a response but the upload actually completed, retrying creates a second test run tagged with the same commit and branch.
Retry safely by distinguishing the failure mode:
- curl never got a response (connection refused, DNS failure, timeout before headers): safe to retry, nothing was created server-side in most of these cases.
- curl got a 5xx: the request was received but failed server-side; retrying is generally fine since these paths clean up on failure, but if you’re not sure, check first.
- curl got any 2xx, or you’re unsure whether it landed: query the upload-sessions API for an existing session with the same
commitShabefore uploading again.
curl -s "https://app.gaffer.sh/api/v1/user/projects/$PROJECT_ID/upload-sessions?commitSha=$(git rev-parse HEAD)" \ -H "X-API-Key: YOUR_PROJECT_TOKEN" | jq '.sessions | length'How do I upload from a CI system without a Gaffer guide?
Section titled “How do I upload from a CI system without a Gaffer guide?”Read git metadata directly instead of relying on CI-specific environment variables, since those are the one thing that won’t be documented for a CI system Gaffer hasn’t written a guide for. This pattern works in Drone, Buildkite, TeamCity, Woodpecker, or a bare Docker container, anywhere a git checkout and curl are available:
#!/bin/bashset -euo pipefail
REPORT_PATH="${1:?Usage: upload.sh <path-to-report>}"COMMIT_SHA=$(git rev-parse HEAD)BRANCH=$(git rev-parse --abbrev-ref HEAD)
STATUS=$(curl -s -o /tmp/gaffer-response.json -w "%{http_code}" \ -X POST https://app.gaffer.sh/api/upload \ -H "X-API-Key: $GAFFER_PROJECT_TOKEN" \ -F "files=@${REPORT_PATH}" \ -F "tags={\"commitSha\":\"${COMMIT_SHA}\",\"branch\":\"${BRANCH}\"}")
if [ "$STATUS" -ge 300 ]; then echo "Gaffer upload failed with HTTP $STATUS:" >&2 cat /tmp/gaffer-response.json >&2 exit 1fi
echo "Uploaded to Gaffer: $(jq -r '.testRun.id' /tmp/gaffer-response.json)"Run it as a post-test step, whatever your CI calls that concept: after_script, post, a second pipeline step gated on the test step’s exit code. The important part is running it even when tests fail, since a failing run is usually the one you most want a shareable report for.
Does uploading to Gaffer require a specific curl version?
Section titled “Does uploading to Gaffer require a specific curl version?”No. Every example on this page uses -F for multipart fields, which curl has supported since version 7.1 (1998). Any curl your CI image ships with will work. The only thing to check is that -F sends files as multipart/form-data, not as a raw request body.
Can I upload with wget or another HTTP client instead of curl?
Section titled “Can I upload with wget or another HTTP client instead of curl?”Yes. The endpoint is plain HTTP: a POST with an X-API-Key header and a multipart/form-data body. Any client that can build a multipart request works, including wget --post-file, Python’s requests library, or a raw socket. curl is used here because every CI image already has it.
Why do I get “No files provided” even though I passed -F?
Section titled “Why do I get “No files provided” even though I passed -F?”The field name has to be exactly files, and the value has to start with @ so curl reads the file from disk instead of sending the path as a literal string. Using file instead of files, or leaving off the @, both trigger this error.
Next Steps
Section titled “Next Steps”- Upload API Reference - Field-by-field reference for the single-POST endpoint
- GitHub Action - Automate uploads without hand-writing the multipart flow