> For the complete documentation index, see [llms.txt](https://engyon.gitbook.io/engyon/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://engyon.gitbook.io/engyon/en/api/use-cases/upload-a-file.md).

# Uploading a file

This use case shows how to link a file to a **file request** via the API in an existing audit.

You upload files **directly to storage** (S3). The Engyon API provides a short-lived *upload intent*: a reserved file ID, a *presigned* POST to S3, and a `token` with which you subsequently confirm the upload. The file content does **not** pass through the application.

We continue with the **Fictiva** example from [Creating an audit](/engyon/en/api/use-cases/creating-an-audit.md).

**Required service account role:** API audit manager (`api_audit_manager`)

{% hint style="danger" %}
**Multipart upload is deprecated and will disappear soon.**

The endpoint `POST /api/v1/audits/{audit_id}/sources/files/` still accepts `multipart/form-data` where you send the file content through the application. This route is **deprecated** and will be removed in the near future.

Use only the *direct upload* flow on this page (`upload-intent` -> S3 -> confirm with `token`). Do not build new integrations on multipart, and migrate existing integrations as soon as possible.
{% endhint %}

#### Concepts

| Term              | Meaning in the API                                                                                                        |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **File request**  | A request in the audit to which client files are linked. You always upload to an existing, active file request.           |
| **Upload intent** | A reservation of an object in storage. No file record exists yet. The response contains the S3 upload data and a `token`. |
| **Direct upload** | You POST the file content to the `upload.url` from the intent (S3), not to the Engyon API.                                |
| **Confirming**    | After the S3 upload, you POST the `token` to the *create* endpoint. Only then is the file record created in Engyon.       |

{% hint style="info" %}
Via the API, you can **only** create files that belong to a file request. Audit documentation (internal files without a file request) cannot be created via this endpoint.
{% endhint %}

#### Flow overview

1. Look up the file request (and check that uploads are allowed)
2. Request an upload intent
3. Send the file content directly to S3
4. Confirm the upload with the `token` — this creates the file
5. (Optional) Poll until the file is processed / indexed

```mermaid
sequenceDiagram
    participant I as Integratie
    participant API as Engyon API
    participant S3 as Opslag (S3)

    I->>API: GET /audits/{id}/sources/file-requests/
    API-->>I: Documentverzoeken (id, status)
    I->>API: POST /audits/{id}/sources/files/upload-intent/
    API-->>I: file.id, upload.url, upload.fields, token
    I->>S3: POST bestandsinhoud (multipart, velden + file)
    S3-->>I: 201 Created
    I->>API: POST /audits/{id}/sources/files/ (token + file_request)
    API-->>I: Bestandsrecord (201 Created)
```

#### Requirements

| Needed         | Endpoint                                                  | Field to use                                                     |
| -------------- | --------------------------------------------------------- | ---------------------------------------------------------------- |
| Existing audit | `GET /api/v1/audits/` or `GET /api/v1/audits/{audit_id}/` | `{audit_id}` — the external ID, for example `fictiva-audit-2025` |
| File request   | `GET /api/v1/audits/{audit_id}/sources/file-requests/`    | `id` of the file request as `file_request`                       |
| File on disk   | —                                                         | Filename, size in bytes, and the path for the S3 upload          |

The audit must exist and be `open`. See [Creating an audit](/engyon/en/api/use-cases/creating-an-audit.md) if you still need to set it up.

**Retrieving file requests**

```bash
curl -X GET "https://audit.engyon.io/api/v1/audits/fictiva-audit-2025/sources/file-requests/" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/json"
```

Choose a file request where:

* `specification_status` is `completed` (the specification is finished)
* `is_not_applicable` is `false`

Only then does the file request accept uploads. In a group audit, you may also use a file request from a component audit when calling the *upload intent* and the confirmation on the group audit.

{% hint style="warning" %}
A file request can enforce allowed file extensions (for example, only `.pdf`). This restriction is checked during **confirmation**, based on the `filename` from the intent. Send a file type that the file request allows.
{% endhint %}

#### 1. Requesting an upload intent

This reserves an object in storage and gives you everything you need to upload the file *outside* the API. **No** file record is created yet.

```
POST /api/v1/audits/{audit_id}/sources/files/upload-intent/
```

**Request fields**

| Field      | Type    | Required | Description                                                                                                      |
| ---------- | ------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
| `filename` | string  | yes      | Filename including extension, for example `bankstatement.pdf`. This name becomes the display name of the file.   |
| `size`     | integer | yes      | File size in **bytes**. Minimum `1`. Maximum **1 GB** (`1073741824`). See below how to calculate this.           |
| `id`       | string  | no       | External ID of the file. Unique within the audit. Immutable after creation. If omitted, Engyon generates a UUID. |

**Determining file size**

`size` is the exact number of bytes of the file you subsequently send to S3 — not kilobytes or a rounded value. Determine this value on the file itself, just before you request the intent.

```bash
# Linux
stat -c %s ./bankstatement.pdf

# macOS
stat -f %z ./bankstatement.pdf

# Everywhere wc is available
wc -c < ./bankstatement.pdf
```

In Python:

```python
from pathlib import Path

size = Path("bankstatement.pdf").stat().st_size
```

The number you get back (for example `245760`) is entered unchanged as `size`. An intent for a file larger than 1 GB is rejected with `400`.

**Example**

```bash
FILE_PATH="./bankstatement.pdf"
FILE_SIZE=$(stat -c %s "$FILE_PATH")   # on macOS: stat -f %z "$FILE_PATH"

curl -X POST "https://audit.engyon.io/api/v1/audits/fictiva-audit-2025/sources/files/upload-intent/" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d "{
    \"id\": \"fictiva-bank-statement-2025-01\",
    \"filename\": \"bankstatement.pdf\",
    \"size\": ${FILE_SIZE}
  }"
```

**Response (`201 Created`)**

```json
{
  "file": {
    "id": "fictiva-bank-statement-2025-01",
    "status": "awaiting_upload"
  },
  "upload": {
    "url": "https://s3.eu-central-1.amazonaws.com/example-bucket",
    "fields": {
      "key": "private-media/orgs/.../audit_files/fictiva-bank-statement-2025-01.pdf",
      "policy": "...",
      "x-amz-algorithm": "AWS4-HMAC-SHA256",
      "x-amz-credential": "...",
      "x-amz-date": "...",
      "x-amz-signature": "...",
      "x-amz-server-side-encryption": "AES256",
      "success_action_status": "201"
    },
    "expires_at": "2026-08-17T13:15:00+00:00"
  },
  "token": "eyJ..."
}
```

| Field               | Description                                                                               |
| ------------------- | ----------------------------------------------------------------------------------------- |
| `file.id`           | Reserved external ID. Use the same value later as `{file_id}`.                            |
| `file.status`       | Always `awaiting_upload` — the record does not exist yet.                                 |
| `upload.url`        | URL to which you POST the file content. This is **S3**, not the Engyon API.               |
| `upload.fields`     | Mandatory form fields for the S3 POST. Send them **all**, unchanged.                      |
| `upload.expires_at` | Moment the *presigned* POST expires (ISO 8601, UTC).                                      |
| `token`             | Signed token. Send this during confirmation. Valid until the same moment as `expires_at`. |

{% hint style="warning" %}
The *presigned* POST and the `token` are valid for **10 minutes**. Upload the file to S3 and confirm within that time. After expiration, you will receive an error during the S3 upload or `400` with an invalid/expired token.
{% endhint %}

#### 2. Uploading the file directly to S3

POST the file content as `multipart/form-data` to `upload.url`.

* Do **not** send an `Authorization: Bearer` header. Authentication is in `upload.fields`.
* Send **every** field from `upload.fields` as a form field, with the exact value from the response.
* The form field `file` must come **last**. S3 rejects the request if `file` is placed earlier.
* A successful upload returns **`201 Created`** (`success_action_status`).

Save the intent response and build the S3 call from it, for example:

```bash
# intent.json contains the response from step 1
UPLOAD_URL=$(jq -r '.upload.url' intent.json)

FORM_ARGS=()
while IFS= read -r line; do
  FORM_ARGS+=(-F "$line")
done < <(jq -r '.upload.fields | to_entries[] | "\(.key)=\(.value)"' intent.json)

curl -X POST "$UPLOAD_URL" \
  "${FORM_ARGS[@]}" \
  -F "file=@./bankstatement.pdf;filename=bankstatement.pdf"
```

The `-F` values above are the same keys as in `upload.fields` (`key`, `policy`, `x-amz-algorithm`, `x-amz-credential`, `x-amz-date`, `x-amz-signature`, `x-amz-server-side-encryption`, `success_action_status`, and any extra fields that Engyon adds). Do not add extra S3 fields yourself and do not change the values.

#### 3. Confirming the upload (creating the file)

After a successful S3 upload, you confirm with Engyon. The API checks that the object is in storage, links it to the file request, and creates the file record.

```
POST /api/v1/audits/{audit_id}/sources/files/
```

`Content-Type` is `application/json`. Send the `token` from the intent; **not** the file content.

**Request fields**

| Field          | Type    | Required | Description                                                                                                                           |
| -------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `token`        | string  | yes      | The `token` from the upload intent response. Determines which object and which filename are recorded.                                 |
| `file_request` | string  | yes      | External ID of the file request (`id` from `GET .../file-requests/`).                                                                 |
| `description`  | string  | no       | Optional explanation for the file.                                                                                                    |
| `is_permanent` | boolean | no       | Default `false`. If `true`, the file moves to subsequent audits during *roll forward*. Only meaningful for files with a file request. |

**Example**

```bash
curl -X POST "https://audit.engyon.io/api/v1/audits/fictiva-audit-2025/sources/files/" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "token": "eyJ...",
    "file_request": "bank-statements",
    "description": "Bank statement January 2025",
    "is_permanent": false
  }'
```

**Response (`201 Created`)**

```json
{
  "id": "fictiva-bank-statement-2025-01",
  "name": "bankstatement.pdf",
  "description": "Bank statement January 2025",
  "is_indexed": false,
  "is_approved": true,
  "is_permanent": false,
  "status": "ready",
  "status_detail": null,
  "file_request": "bank-statements"
}
```

| Field           | Description                                                   |
| --------------- | ------------------------------------------------------------- |
| `id`            | External ID of the file                                       |
| `name`          | Display name (the `filename` from the intent)                 |
| `description`   | Explanation, or `null`                                        |
| `is_indexed`    | Whether the file is indexed (searchable). Starts as `false`.  |
| `is_approved`   | Whether the file is approved                                  |
| `is_permanent`  | Whether the file moves with *roll forward*                    |
| `status`        | Processing status: `ready`, `converting` or `failed`          |
| `status_detail` | Error explanation when `status` is `failed`, otherwise `null` |
| `file_request`  | ID of the linked file request                                 |

**What happens after confirmation**

* **PDF** — `status` is usually `ready` immediately. Indexing starts in the background; `is_indexed` later becomes `true`.
* **Office file** (Word, Excel, and similar types) — Engyon first sets the file to `converting` and asynchronously creates a PDF. Then `status` becomes `ready` (or `failed` if conversion fails). Indexing starts only after a successful conversion.
* **Other file type** (for example `.xaf`) — `status` is `ready` immediately. The file is not converted to PDF and not indexed (`is_indexed` remains `false`).
* If the object is missing from storage, the `token` is invalid or expired, or the token belongs to another audit, you will receive `400`.

#### 4. Requesting status

Directly after creation, the file is not necessarily indexed. Poll the detail endpoint if you need to wait until processing is finished.

```bash
curl -X GET "https://audit.engyon.io/api/v1/audits/fictiva-audit-2025/sources/files/fictiva-bank-statement-2025-01/" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/json"
```

| Situation                        | What you see                                       |
| -------------------------------- | -------------------------------------------------- |
| Conversion in progress           | `status`: `converting`, `is_indexed`: `false`      |
| File ready, indexing in progress | `status`: `ready`, `is_indexed`: `false`           |
| Ready for use in the audit       | `status`: `ready`, `is_indexed`: `true`            |
| Conversion failed                | `status`: `failed`, explanation in `status_detail` |

All files for the audit:

```bash
curl -X GET "https://audit.engyon.io/api/v1/audits/fictiva-audit-2025/sources/files/" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Accept: application/json"
```

Optional filters: `is_indexed`, `is_approved`. The *list* endpoint is paginated (see Basic Operations).

#### Updating a file

You can still change `is_permanent` later. Other fields are not modifiable via the API. Follower files (files automatically taken from a master) cannot be updated directly; modify the master file.

```
PATCH /api/v1/audits/{audit_id}/sources/files/{file_id}/
```

```bash
curl -X PATCH "https://audit.engyon.io/api/v1/audits/fictiva-audit-2025/sources/files/fictiva-bank-statement-2025-01/" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "is_permanent": true
  }'
```

Files from an archived audit cannot be updated.

#### Limitations and errors

| Situation                                        | Result                                                                                           |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| File larger than 1 GB                            | `400` on the intent (`size`: file too large). S3 also rejects objects outside the allowed range. |
| Intent or token older than 10 minutes            | S3 rejects the upload, or `400` during confirmation (`token` invalid or expired).                |
| Confirming without the object being in S3        | `400` (`token`: file not found in storage).                                                      |
| Token belongs to another audit                   | `400` (`token` does not belong to this audit).                                                   |
| Unknown, inactive or non-uploadable file request | `400` on `file_request`.                                                                         |
| File extension not allowed for the file request  | `400` during confirmation.                                                                       |
| Duplicate `id` within the audit                  | `400` on `id` (already at the intent).                                                           |
| No `create:file` right                           | `403 Forbidden`.                                                                                 |
| Audit not found or not from your office          | `404 Not Found`.                                                                                 |

The service account needs the role **API audit manager**. Without this role, both the intent and the confirmation fail with `403`.

#### Recommended order for Fictiva

1. Ensure audit `fictiva-audit-2025` exists and is `open` ([Creating an audit](/engyon/en/api/use-cases/creating-an-audit.md))
2. Retrieve file requests and choose, for example, `bank-statements` (specification completed, not *not applicable*)
3. Request an upload intent with `filename`, `size` and optionally a stable `id`
4. POST the file content to `upload.url` with all `upload.fields`; place `file` last
5. Confirm within 10 minutes with `token` + `file_request`
6. Poll the file until `status` is `ready` (and until `is_indexed` is `true` if you need indexing)

#### Deprecated multipart route (do not use)

`POST /api/v1/audits/{audit_id}/sources/files/` still recognizes a body **without** a `token` as `multipart/form-data` with a `file` field. This variant streams the content through the application.

{% hint style="danger" %}
This variant is **deprecated** and will be **removed shortly**. New integrations must use the three steps above. Existing multipart clients must switch before the route disappears.
{% endhint %}

#### Related endpoints

| Method  | Path                                                                 |
| ------- | -------------------------------------------------------------------- |
| `GET`   | `/api/v1/audits/{audit_id}/sources/file-requests/`                   |
| `GET`   | `/api/v1/audits/{audit_id}/sources/file-requests/{file_request_id}/` |
| `POST`  | `/api/v1/audits/{audit_id}/sources/files/upload-intent/`             |
| `GET`   | `/api/v1/audits/{audit_id}/sources/files/`                           |
| `POST`  | `/api/v1/audits/{audit_id}/sources/files/`                           |
| `GET`   | `/api/v1/audits/{audit_id}/sources/files/{file_id}/`                 |
| `PATCH` | `/api/v1/audits/{audit_id}/sources/files/{file_id}/`                 |

For the complete and current list of request fields and response schemas, use the interactive API reference (Swagger). Use-case pages focus on how to combine endpoints into a working flow.
