# Files and Documents

In Aerion, **users**, **projects**, **clients**, and **expenses** can have files associated with them — offers, purchase orders, contracts, NDAs, briefs, scanned IDs, certifications, receipts, and so on. Files never exist on their own; they always hang off one of these four record types.

The shape differs depending on which:

- **Users, projects, and clients** can have **many Documents**, each holding a single file plus its own metadata (name, category, date).
- **Expenses** carry **one file, directly** — no Document wrapper, the file attaches straight to the Expense.

This page covers the conceptual model and the gotchas you can't see in the [API documentation](/api). For full request/response schemas, follow the links into the [Document](/api/document), [File](/api/file), and [Expense](/api/expense) reference.

## The Document object

For users, projects, and clients, the file isn't attached directly — there's a **Document** record in between. Each Document holds one file plus its descriptive metadata, and points at its parent via a `(model, record)` pair:

| `model`     | Parent  | Typical use                                          |
| ----------- | ------- | ---------------------------------------------------- |
| `user`      | User    | HR documents on an employee (IDs, certifications)    |
| `project`   | Project | Offers, purchase orders, contracts, briefs           |
| `client`    | Client  | Client-level agreements, NDAs                        |

A single parent can have any number of Documents — each one is a separate record with its own ID, which is what you upload the file against. See [`POST /v1/documents`](/api/document#create-document) for the full field list.

**Expenses skip the Document wrapper.** An [Expense](/api/expense) is a record type in its own right — a cost booked against a project — and its file (typically the receipt) attaches directly to the Expense ID. Same upload mechanics, just `expense/{expenseId}` instead of `document/{documentId}`, and only one file per Expense.

Profile pictures, client logos and account branding go through a separate `/v1/images/...` endpoint family that is not covered here.

## The two-step upload flow

Every attachment is a two-step flow:

1. **Create the owning record** — a Document via [`POST /v1/documents`](/api/document#create-document), or an Expense via [`POST /v1/expenses`](/api/expense#create-expense). You get back the record's `id`.
2. **Upload the binary** against that record via [`POST /v1/files/upload/{model}/{record}`](/api/file#attach-a-file-to-a-record). The `model` is `document` or `expense`; `{record}` is the ID from step 1. You get back an **upload ID** used for downloads and deletes.

> **If step 2 fails, delete the Document you created in step 1.** The server does not clean up orphaned Document records, and they will show up in listings with no attached file.

## Document categories

`category` is a numeric enum, **scoped to the parent `model`**. A `user` category will not validate against a `project` Document.

| ID | Key                    | Valid for `model` |
| -- | ---------------------- | ----------------- |
| 1  | `employmentContract`   | `user`            |
| 2  | `targetAgreement`      | `user`            |
| 3  | `medicalCertificate`   | `user`            |
| 4  | `applicationDocuments` | `user`            |
| 5  | `testimonials`         | `user`            |
| 6  | `miscellaneous`        | `user`            |
| 7  | `offer`                | `project`         |
| 8  | `clearance`            | `project`         |
| 9  | `nda`                  | `project`         |
| 10 | `miscellaneous`        | `project`         |
| 11 | `contract`             | `client`          |
| 12 | `agreement`            | `client`          |
| 13 | `nda`                  | `client`          |
| 14 | `miscellaneous`        | `client`          |

### `visibleToUser`

Only meaningful when `model = "user"`: controls whether the employee can see their own document. Ignored on `project` and `client` Documents.

## Uploading the file

```http
POST /v1/files/upload/{model}/{record}?delete_others=1
Content-Type: multipart/form-data
```

`{model}` is `document` or `expense`. The body is a `multipart/form-data` payload with a single `file` field carrying the binary. Full spec: [Attach a file to a record](/api/file#attach-a-file-to-a-record).

A few things that aren't visible from the spec alone:

- **Always send `?delete_others=1`.** Documents and Expenses accept only one file each. Without this flag, a second upload to the same record returns `403 "too many uploads"`. With it, any previously attached file is replaced.
- **Filenames are sanitized.** The server strips everything except letters, digits, `_`, `.` and German umlauts (`ä ö ü Ä Ö Ü ß`). Dashes, spaces and punctuation are removed — `invoice-2026 (final).pdf` becomes `invoice2026final.pdf`. Read the returned `filename`; don't assume it matches what you sent.
- **Per-upload and per-account caps are enforced.** Oversized files return `403`; an account at its storage cap also returns `403`. Check with your account administrator for your specific limits.
- **Uploads may be rate limited.** A `429` response indicates you've hit the limit — wait a moment and retry.
- **Server-side clients must buffer the file before posting it.** The backend reads the upload size from the `Content-Length` of the `file` part. Streaming with chunked transfer encoding leaves it undefined and the upload fails validation with a misleading `500 "Missing value for required attribute size"`. If your HTTP client streams by default (`node-fetch` + `form-data` with a stream, `undici`, `axios` with a stream body), buffer the file into memory first or pass an explicit `knownLength` to your form library. Browsers and `curl -F` send `Content-Length` automatically and aren't affected.

The response includes the **upload ID**, which is what you use for downloads and deletes — not the owning record's ID.

## Listing what's attached

```http
GET /v1/files/list/{model}/{record}
```

Returns the upload(s) on a given record. Since Documents and Expenses carry at most one file each, this list is either empty or has a single entry. Full spec: [List uploads attached to a record](/api/file#list-uploads-attached-to-a-record).

To list all Documents on a project (or client/user) use the Document endpoint with a `where` filter:

```http
GET /v1/documents?where={"record":1234,"model":"project"}&sort=date desc
```

The `where` value is JSON and must be URL-encoded. Full spec: [List Document](/api/document#list-document-find-where).

## Downloading a file

```http
POST /v1/files/get-url/{uploadId}
```

Returns a **signed S3 URL** as a JSON string. Full spec: [Signed URL for downloading an upload](/api/file#signed-url-for-downloading-an-upload).

- **The URL is valid for 30 seconds.** Download immediately — don't store or cache it.
- Issue a plain `GET` against the URL. No `Authorization` header — the query-string signature grants access.
- This endpoint may be rate limited; a `429` indicates you've hit the limit — wait a moment and retry.

## Deleting

```http
POST /v1/files/delete/{uploadId}
```

Removes the binary and the upload record. The owning Document or Expense is **not** deleted — remove it separately via [`DELETE /v1/documents/{id}`](/api/document#delete-document-destroy) or [`DELETE /v1/expenses/{id}`](/api/expense#delete-expense-destroy) if you want it gone too. Full spec: [Delete an upload](/api/file#delete-an-upload).

## Error reference

| Status                       | Meaning                                                           | What to do                                          |
| ---------------------------- | ----------------------------------------------------------------- | --------------------------------------------------- |
| `400`                        | Malformed request (e.g. missing `file` field in the multipart body) | Check field names and content types                 |
| `401`                        | Missing or expired token                                          | Refresh the OAuth token (see [Authentication](/developer/authentication)) |
| `403` `"too many uploads"`   | Record already has a file and `delete_others` was not set         | Add `?delete_others=1`                              |
| `403` (size/storage)         | File exceeds per-upload or account storage limit                  | Reduce file size or contact your account admin      |
| `404`                        | Record or upload not found, or no permission                      | Verify IDs and that the token has access            |
| `429`                        | Rate limit exceeded                                               | Wait a moment and retry                             |

## End-to-end example

Attaching an offer PDF to project `1234`:

```bash
# 1. Create the Document record
curl -X POST https://your-account.aerion.app/v1/documents \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "document": {
      "record": 1234,
      "model": "project",
      "name": "Project Offer",
      "date": "2026-04-24",
      "category": "10",
      "createdBy": "6789",
      "visibleToUser": false
    }
  }'
# → { "document": { "id": 2345, ... } }

# 2. Upload the file (note delete_others=1)
curl -X POST "https://your-account.aerion.app/v1/files/upload/document/2345?delete_others=1" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@./offer.pdf;type=application/pdf"
# → { "id": 3456, "filename": "offer.pdf", "type": "application/pdf", "size": 524288 }

# 3. Later: get a 30-second signed download URL
curl -X POST https://your-account.aerion.app/v1/files/get-url/3456 \
  -H "Authorization: Bearer $TOKEN"
# → "https://{bucket}.s3.eu-central-1.amazonaws.com/..."
```

Attaching a receipt to an Expense is identical — create the Expense first via [`POST /v1/expenses`](/api/expense#create-expense), then `POST /v1/files/upload/expense/{expenseId}?delete_others=1`.
