# Upload a report

> Send a shop's sales export to Panlytics through the API, to start a new category or bring the next period into one that exists.

Markdown twin of https://panlytics.net/developers/guides/upload-a-report (last updated 2026-10-25). Every page of the Panlytics developer docs is listed at https://panlytics.net/developers/llms.txt, and all of them are in one file at https://panlytics.net/developers/llms-full.txt. The site guide is https://panlytics.net/llms.txt.

## What you need

- A key whose maker's role holds **Edit the sheet and upload a report**. A new category needs it over the whole shop; the next report of a category needs it in that category.
- The shop's id. `GET /v1/shops` lists the shops the key reaches, with their ids.
- The report: the sales export your till or ERP already makes, as `.xlsx` or `.csv`. The limit is the same as in the app: at most 8 MB.

The full request and answer are on the reference page, [POST /v1/shops/{id}/reports](https://panlytics.net/developers/api/reports-upload).

## Start a new category

Send the file as `multipart/form-data`, with a `category` field naming the new category and `periodDays`, the number of days the report covers:

```bash title="curl"
curl -X POST https://panlytics.net/api/v1/shops/12/reports \
  -H "Authorization: Bearer $PANLYTICS_KEY" \
  -H "Idempotency-Key: soft-drinks-2026-10-25" \
  -F "category=Soft drinks" \
  -F "periodDays=30" \
  -F "file=@soft-drinks.xlsx"
```
```python title="Python"
import os
import requests

with open("soft-drinks.xlsx", "rb") as report:
    answer = requests.post(
        "https://panlytics.net/api/v1/shops/12/reports",
        headers={
            "Authorization": f"Bearer {os.environ['PANLYTICS_KEY']}",
            "Idempotency-Key": "soft-drinks-2026-10-25",
        },
        data={"category": "Soft drinks", "periodDays": "30"},
        files={"file": report},
        timeout=120,
    )
answer.raise_for_status()
print(answer.json())
```
```typescript title="TypeScript"
import { readFile } from "node:fs/promises";

const form = new FormData();
form.set("category", "Soft drinks");
form.set("periodDays", "30");
form.set("file", new Blob([await readFile("soft-drinks.xlsx")]), "soft-drinks.xlsx");

const answer = await fetch("https://panlytics.net/api/v1/shops/12/reports", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PANLYTICS_KEY}`,
    "Idempotency-Key": "soft-drinks-2026-10-25",
  },
  body: form,
});
console.log(answer.status, await answer.json());
```

The answer is `201` with the new report and its category:

```json title="201"
{
  "reportId": 311,
  "categoryId": 40,
  "shopId": 12,
  "newCategory": true,
  "rows": 412,
  "deduplicated": false
}
```

Panlytics reads the rows, repairs what it can and flags what it cannot, exactly as it does for a report uploaded in the app. `deduplicated` is true when this exact file was uploaded before. If the shop already has a category with that name, the report becomes that category's next report, and `newCategory` is false.

## Bring the next period into a category

To file the next report into a category that exists, send `categoryId` instead of `category`. `periodDays` is optional here: left out, the category keeps its period. `GET /v1/shops/{id}/categories` lists the categories with their ids.

```bash title="curl"
curl -X POST https://panlytics.net/api/v1/shops/12/reports \
  -H "Authorization: Bearer $PANLYTICS_KEY" \
  -H "Idempotency-Key: soft-drinks-2026-11-01" \
  -F "categoryId=40" \
  -F "file=@soft-drinks-november.xlsx"
```

## Send it once

> [!TIP]
> Always send an `Idempotency-Key` header with a value of your own, such as the category and the date. If the network drops and you send the same upload again with the same key within 24 hours, Panlytics answers with the first result instead of filing the report twice. The same key with a different file answers `409 api.idempotency_conflict`. See [Idempotency and retries](https://panlytics.net/developers/api#idempotency-and-retries).

## When it is refused

| Code | Status | What to change |
| --- | --- | --- |
| `api.invalid` | 400 | `detail` names the field: no `file`, both or neither of `category` and `categoryId`, or no `periodDays` for a new category. |
| `upload.said` | 400 | The file failed a check, such as its type. `detail` says what. Send `.xlsx` or `.csv`. |
| `upload.too_large` | 413 | Send a file under 8 MB. |
| `form.field` | 422 | What the file holds was refused. `detail` says why. |
| `refused` | 403 | The key's maker may not upload to this shop or category. |
| `refused.demo` | 403 | A shared demo account cannot upload. |
| `not_found.said` | 404 | The shop or category id is wrong, or the category is not in this shop. |

An agent should ask its person before it uploads anything, as the agent prompt tells it to. Every upload is written to the audit log under the maker's name.
