> ## Documentation Index
> Fetch the complete documentation index at: https://docs.peepsai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Send results from your harness

> Send JUnit XML or pytest-html report files to a Peeps release from your CI job or test harness, one HTTP request per file.

Your harness sends each report file to Peeps with one `PUT` request. Peeps files the results under your test cases, in a release of the project. Peeps never re-runs, heals or fixes these tests; it records and analyzes what your harness produced.

Before you start, [create an API key](/api-reference/api-keys) and store it in your CI system's secret store. The examples below read it from `PEEPS_API_KEY`.

## What to send

* **One request per report file.** Peeps reads JUnit XML (`.xml`) and pytest-html 4 (`.html`) reports.
* **Send the JUnit XML when a run produces both.** Each request is its own upload, and Peeps pairs a JUnit XML with the HTML report of the same run only within one upload. Sent separately, the two aren't paired, so send only the JUnit XML.
* **pytest-html must be version 4 or later.** Peeps refuses a pytest-html 3 report with `422 pytest_html_v3`. Run `pip install -U 'pytest-html>=4'`, or send the JUnit XML.
* **Peeps checks what the file holds, not its name.** A file that is neither a JUnit XML nor a pytest-html report is refused with `422 unsupported_format`, and Peeps stores none of it.
* **Up to 50 MiB per file.**

## Choose the release

Add `?release=` to name the release these results belong to, such as `?release=2.0`. A release name is 1 to 80 letters, digits, spaces and `. _ + - ( )`, starting with a letter or digit.

Leave it out and Peeps uses the project's current release. You set that in the project's settings, under **General**, in **Current release**. If you leave out `release` and the project has no current release, Peeps refuses the file with `422 release_required`.

## Send a file with curl

```bash theme={null}
curl --fail-with-body -sS -T junit.xml \
  -H "Authorization: Bearer $PEEPS_API_KEY" \
  "https://app.peepsai.com/api/v1/projects/WEB/results/junit.xml?release=2.0"
```

Replace `WEB` with your project's acronym and `junit.xml` with the file's name. The name in the URL is the name Peeps shows for the file: 1 to 255 characters, with no `/`, `\` or control characters, ending in `.xml` or `.html` in any case.

URL-encode the file name in the URL and the `release` value. For example, release `2.0+build` becomes `?release=2.0%2Bbuild`, and a space in a file name becomes `%20`. Pass the local file to `-T` as it is, quoted if its name has spaces.

`-T` sends the file as the request body with its size, which Peeps requires. `--fail-with-body` makes curl exit with an error on a refusal and still print Peeps's message. It needs curl 7.76 or later.

## Send files from Python

Use this in a CI step after your tests finish, or in a folder watcher that sends each new report. If your harness already uploads its reports to another tool, call it beside that upload or in its place.

```python theme={null}
import os
import time
from pathlib import Path
from urllib.parse import quote

import requests

PEEPS_API = "https://app.peepsai.com/api/v1"
PROJECT = "WEB"   # your project's acronym
RELEASE = "2.0"   # or None to use the project's current release


def send_report(path: Path) -> dict:
    url = f"{PEEPS_API}/projects/{PROJECT}/results/{quote(path.name, safe='')}"
    params = {"release": RELEASE} if RELEASE else None
    headers = {"Authorization": f"Bearer {os.environ['PEEPS_API_KEY']}"}
    for _ in range(5):
        with path.open("rb") as report:
            response = requests.put(
                url, params=params, headers=headers, data=report, timeout=180
            )
        if response.status_code not in (429, 500, 503):
            break
        # Peeps is busy or the key hit its rate limit: wait as long as it says.
        time.sleep(int(response.headers.get("Retry-After", "10")))
    response.raise_for_status()
    return response.json()


for report in sorted(Path("reports").glob("*.xml")):
    result = send_report(report)
    print(report.name, result["file"]["status"], result["statusUrl"])
```

Passing the open file as `data` sends it with its size. `quote(..., safe="")` encodes the file name, and `params=` encodes the release. Send files one at a time, as this loop does: Peeps answers `503 busy` when your organization's API keys already have six uploads in progress, and the retry waits for them.

## Sending again is safe

Retrying after a timeout or a lost connection doesn't file the results twice.

* **Peeps remembers each send for 24 hours.** Within that time, a resend with the same key, project, file name, bytes and `release` returns the original upload. That includes leaving out `release` both times. If the original upload failed or expired, the resend replaces it with a new one.
* **Files already filed are recognized by their bytes.** After 24 hours, or with a different file name or `release`, the same file returns `already_in_peeps` and isn't filed again.

If you'd rather name each request yourself, send an `Idempotency-Key` header with your own value, 1 to 200 printable ASCII characters. Within those 24 hours, reusing it with a different project, file name, file or `release` is refused with `409 idempotency_mismatch`.

## Read the response

A file Peeps stored returns `202` with the `uploadId`, the `project` and `release` it is filed under, the `file` with its `status`, the upload's `state`, a `statusUrl` and a `reportUrl`. [Send a report file](/api-reference/results/send-a-report-file#response) describes every field and shows an example.

`file.status` says what happened to the file:

| `file.status` | Status code | What it means |
| - | - | - |
| `received` | `202` | Peeps stored the file and is filing its results. |
| `received` | `200` | Peeps already had this request from an earlier send. Nothing changed. |
| `already_in_peeps` | `200` | The exact same file is already filed in this project, from any source. Peeps doesn't file it again. |

`reportUrl` usually opens the release in Peeps. If Peeps masks the release name, it opens the project's test cases page instead. `statusUrl` is where you check on this upload.

## Check an upload

Call the `statusUrl` with the same key:

```bash theme={null}
curl --fail-with-body -sS \
  -H "Authorization: Bearer $PEEPS_API_KEY" \
  "https://app.peepsai.com/api/v1/results/uploads/8f0c6a52-3d1e-4f7b-9a4e-2b6f1c0d9e7a"
```

The response holds the upload's `state`, how many tests are `waitingForAnswers`, the `rejectedFiles` and more. [Get an upload](/api-reference/results/get-an-upload) describes every field and shows an example. `state` tells you where the upload is:

| `state` | What it means |
| - | - |
| `receiving` | Peeps hasn't stored the file yet. |
| `applying` | Peeps is filing the results. |
| `awaiting_answers` | A file looks like a duplicate of a run already filed, and waits until someone answers. |
| `ready` | Peeps has finished processing the upload. Check `rejectedFiles` for a report it couldn't read and `waitingForAnswers` for tests still to map: `ready` alone doesn't mean results were filed. |
| `failed` | Peeps couldn't file the results. Send the file again. |
| `expired` | The file never arrived. Send it again. |

A key can only check uploads it sent itself. Another key's upload answers `404`, as if it didn't exist.

If `rejectedFiles` lists the file, Peeps couldn't read it as a report. Its `code` says why, for example `malformed_report` or `report_too_large`.

## What happens next

Peeps files each result under the test case it belongs to. A test Peeps can't match to a test case yet waits until someone chooses one. `waitingForAnswers` counts those tests. Their results are kept, and they file as soon as a test case is chosen.

To map them, open the `reportUrl` and click **Map tests**. You need the Contributor or Owner role. Once you map a test, later results for it file on their own.

If Peeps thinks a file repeats a run it already filed, it holds that file and asks on the release page whether it's the same run. `duplicateQuestions` counts those questions, and the upload stays `awaiting_answers` until someone answers.

## Limits

| Limit | Value |
| - | - |
| File size | 50 MiB per file |
| Time to send the body | 120 seconds per file |
| Sends per key | 30 a minute |
| Sends per organization, across all its keys | 60 a minute |
| Status checks and key checks per key | 120 a minute, shared |
| Uploads in progress from your organization's API keys | 6 at once |

A send counts toward the minute limits once Peeps has checked the key, the request and the project, even if its body then fails to arrive. A send refused before that, such as one with an invalid key or file name, doesn't count. Over a limit, Peeps answers `429 rate_limited` with a `Retry-After` header.

When Peeps is busy, it answers `503 busy` with a `Retry-After` header: a few seconds when it's receiving many files at once, about a minute when your organization's API keys already have six uploads being received or processed. Uploads from other sources don't count toward the six, and neither do tests waiting for someone to map them. A resend of an upload already in progress still goes through. Wait the number of seconds in `Retry-After`, then send the file again.

See the [API reference](/api-reference/introduction) for every endpoint, field and error.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.