# Files and statuses

A **file** is one PDF, image or e-invoice you send. It holds one or more **documents**: a scan of three invoices is one file, three documents.

## Sending a file

`POST /public/v1/files`, `multipart/form-data`, scope `documents:write`:

| Field | Meaning |
| --- | --- |
| `file` | Required. PDF, image (JPEG, PNG, TIFF, WebP) or e-invoice XML (XRechnung, UBL, CII), up to 25 MB. No archives in this version. |
| `name` | The file name; else the upload's. |
| `external_ref` | Your own id, up to 200 characters (Odoo: `account.move,<id>`), returned with the file. Resent bytes keep the first one's. |

| Answer | Meaning |
| --- | --- |
| `202 Accepted` | New file, `status: "received"`; reading starts at once. |
| `200 OK` | The same bytes (by SHA-256, not name) came before: that file as it is now, not read or counted again. |
| `409 urn:ocost:file:kept` | The same bytes are kept where keys don't see them (a person's own upload). Nothing changes. |
| `413 urn:ocost:file:too_large`, `415 urn:ocost:file:format`, `411` | Too large, a format Ocost doesn't read, no `Content-Length`. |

Every answer carries `X-Ocost-Documents-Left`, your plan's documents left this month.

```bash
curl -sS https://api.ocost.io/public/v1/files \
  -H "Authorization: Bearer $OCOST_KEY" \
  -F "file=@scan.pdf" -F "name=scan.pdf" -F "external_ref=account.move,42"
```

## Statuses

`GET /public/v1/files/{file_id}` (scope `documents:read`) answers the file with its `status`:

| Status | Meaning |
| --- | --- |
| `received` | Stored, not read yet. |
| `reading` | Being read; while no reading service answers, Ocost retries for up to a day. |
| `read` | `documents` lists each: `id`, `kind`, `number`, `pages`. |
| `not_read` | Stored, not read; see `reason`. |
| `failed` | Couldn't be read; see `reason`. |

| Reason | With | Meaning |
| --- | --- | --- |
| `limit` | `not_read` | The month's documents are used up; Ocost reads the file on `renews_on`. |
| `model_off` | `not_read` | This business may not have PDFs and images read; e-invoices still are. |
| `nothing_found` | `not_read` | No document in it. |
| `too_large` | `not_read` | Too many pages or too large an image. |
| `damaged` | `failed` | Not a file Ocost can open. |
| `unavailable` | `failed` | No reading service answered for a day; resend the bytes to retry. |
| `failed` | `failed` | Anything else. |
| `other` | either | A reason this API doesn't name yet. |

## Waiting for a file

Poll every 2 to 3 seconds for half a minute, then every 10 to 30. Most PDFs take under a minute, long ones a few.

For many files, list what changed: start with `updated_since` (or nothing, for all), then follow the cursor.

```bash
curl -sS -G https://api.ocost.io/public/v1/files \
  -H "Authorization: Bearer $OCOST_KEY" \
  --data-urlencode "updated_since=2026-11-09T08:00:00Z" \
  --data-urlencode "status=read" --data-urlencode "status=not_read" \
  --data-urlencode "status=failed"
```

- Oldest change first, up to `limit` (1 to 100, default 50), a file once per change.
- While `more` is true, ask with `cursor=<next_cursor>` for the next page; then keep the cursor for your next poll.
- `status` filters after paging, so a page may hold fewer than `limit`: follow `more`, not the count.

Waiting for one file, and collecting everything changed since your last poll:

```python
def wait_for(session, file_id, timeout=300):
    started = time.monotonic()
    while time.monotonic() - started < timeout:
        file = session.get(f"{API}/files/{file_id}", timeout=15).json()
        if file["status"] not in ("received", "reading"):
            return file
        time.sleep(3 if time.monotonic() - started < 30 else 15)
    return file
```

```javascript
// Every file changed since the cursor kept from the last poll (null: all).
async function changedSince(cursor) {
  const files = [];
  for (;;) {
    const params = new URLSearchParams({ limit: '100', ...(cursor ? { cursor } : {}) });
    const page = await fetch(`${API}/files?${params}`, { headers: auth }).then((r) => r.json());
    files.push(...page.files);
    cursor = page.next_cursor ?? cursor;
    if (!page.more) return { files, cursor }; // keep `cursor` for the next poll
  }
}
```

## The original

`GET /public/v1/files/{file_id}/content` answers the bytes as sent.

## Deleting a file

`DELETE /public/v1/files/{file_id}` (scope `documents:write`) erases the file, its documents, what was read, its search index and the original, at once and for good.

| Answer | Meaning |
| --- | --- |
| `204` | Erased; `404` after. |
| `409 urn:ocost:file:reading` | Being read; ask again later. |
| `409 urn:ocost:file:linked` | Ocost still needs it: a booked invoice (paid, matched, exported or not), a bank statement, an open question, a project's file. `detail` says which; delete it in Ocost once nothing does. |
