---
title: Uploads
description: The three-step S3 multipart pipeline — initiate, parallel PUT, complete — and the abort path that keeps S3 clean.
---

Files never pass through the API. The backend authorises the target, mints presigned URLs, and the browser uploads directly to S3.

## The three steps

1. **Initiate**

    ```http
    POST /api/upload/initiate
    Content-Type: application/json

    {
      "fileName": "brand-mark.png",
      "contentType": "image/png",
      "partCount": 5,
      "ownerScope": "organization",
      "collectionId": "CLa7Bk9x2Q",
      "projectId": "PRa7Bk9x2Q",
      "collectionIds": ["CLa7Bk9x2Q", "CLb8Cm0y3R"]
    }
    ```

    The backend authorises the target **before** minting anything, then returns an `uploadId`, a `key`, and one presigned URL per part.

    `collectionId` and `collectionIds` are not alternatives — which one applies depends on `ownerScope`:

    | `ownerScope` | Field used | Checked against |
    | --- | --- | --- |
    | `personal` | `collectionId` | the caller owns that collection |
    | `organization` | `collectionIds` and `projectId` | both belong to the resolved organization |

    The field for the other scope is ignored.

    If the target is not yours, or does not belong to the resolved organization, the call fails **before** any URL is minted: **404 `Collection <id> not found`** or **404 `Project not found`**. It is 404 rather than 403 for the same reason ownership mismatches are — see [Conventions](/api/conventions#errors).

    `partCount` must be between 1 and 10,000. `contentType` must match `/^[\w.+-]+\/[\w.+-]+$/`. `fileName` is capped at 255 characters.

2. **Upload the parts**

    The client `PUT`s each 5 MB chunk straight to S3 in parallel and reads the `ETag` response header from each.

    ```ts title="frontend/src/hooks/useAssetUpload.ts"
    const response = await fetch(presignedUrl, {
      method: "PUT",
      body: chunk,
    });
    const eTag = response.headers.get("ETag");
    ```

    **The part URLs are valid for one hour.** A single failed part can be retried against the same URL for as long as it lasts — the parts are independent, and S3 accepts a re-`PUT` of the same part number. Past the hour, or if `ETag` comes back `null`, the upload cannot be completed: abort it and start again.

    :::warning[`ETag` needs CORS to be readable]
    `response.headers.get("ETag")` returns `null` unless the bucket's CORS configuration exposes the header via `ExposeHeaders`. A `null` here is not a failed upload — the part is in S3 — but `complete` will reject without it, so treat it as fatal for the attempt and abort.
    :::

3. **Complete**

    ```http
    POST /api/upload/complete
    Content-Type: application/json

    {
      "uploadId": "…",
      "key": "organizations/ORa7Bk9x2Q/uploads/8f1c6f2e-3d0a-4b7c-9e21-5a0d7c4b1e93-brand-mark.png",
      "contentType": "image/png",
      "parts": [{ "partNumber": 1, "eTag": "…" }],
      "ownerScope": "organization",
      "projectId": "PRa7Bk9x2Q",
      "collectionIds": ["CLa7Bk9x2Q"]
    }
    ```

    The backend finalises the S3 multipart upload, then persists the asset, its first version, and an `asset_logs` row **in a single transaction**. A failure at any point rolls all three back together.

    :::warning[Send back the `key` you were given, unmodified]
    Keys are minted by the server and their prefix is the authorisation check. `complete` and `abort` both re-derive the expected prefix from the caller and the declared `ownerScope`, and reject anything else with **400 `Upload key does not match owner scope`**:

    ```text
    users/<userId>/uploads/<uuid>-<safeFileName>
    organizations/<orgId>/uploads/<uuid>-<safeFileName>
    ```

    The file name is sanitised before it becomes part of the key — path separators and any character outside `[\w.-]` become `_` — so the key you get back will not always contain the name you sent. Store the returned `key` and echo it verbatim; do not reconstruct it.
    :::

## Aborting

```http
POST /api/upload/abort
Content-Type: application/json

{ "uploadId": "…", "key": "…" }
```

An abandoned multipart upload leaves orphaned parts that S3 bills for. Call abort on any failure — the client is the only thing that knows the upload died.

Abort itself is best-effort: if the call fails, the parts stay in S3 and nothing else will clean them up, because no server-side job tracks abandoned uploads. Retry it once, then move on rather than blocking the user. A bucket lifecycle rule to expire incomplete multipart uploads would make this self-healing and does not exist yet.

A malformed or foreign `key` returns **400 `Invalid upload key`**; a key whose prefix does not match the declared `ownerScope` returns **400 `Upload key does not match owner scope`**.

:::warning[There is no idempotency key on complete]
A retry after a network failure on `POST /api/upload/complete` can create a duplicate asset. Abort and restart instead of retrying.
:::

## Uploading a new version

Pass an existing `assetId` and an optional `changeNote` to `complete`, and the pipeline appends an `AssetVersion` rather than creating a new asset:

```json title="POST /api/upload/complete — new version of an existing asset"
{
  "uploadId": "…",
  "key": "…",
  "contentType": "image/png",
  "parts": [{ "partNumber": 1, "eTag": "…" }],
  "assetId": "ASa7Bk9x2Q",
  "changeNote": "Tightened the wordmark spacing"
}
```

`asset_versions` has a unique constraint on `(asset, version_number)`, so two concurrent version uploads cannot both claim the same number — the loser surfaces as a [retryable 409](/api/conventions#errors).

## Collections are optional

`collectionId` is nullable throughout the upload path. An asset can exist attached only to an organization, or to a project, with no collection at all. Both halves support this: the backend persists assets with no collection, and the SPA allows uploads without choosing one.

:::danger[`PATCH /api/assets/:id` did not follow]
Asset update still requires `collectionId` in the body and 400s without it, even for an asset that has no collection. The frontend works around it by always sending one:

```ts title="frontend/src/hooks/useCollectionDetails.ts"
// collectionId is mandatory on this endpoint — the backend 400s without it.
const updateDto: any = { collectionId: collection.id };
```

This is an inconsistency to close, not a rule to design around.
:::

## What lands in the database

| Table | Row |
| --- | --- |
| `assets` | name, type, short + long URL, `current_version: 1`, and whichever of `collection_id` / `owner_user_id` / `organization_id` / `project_id` / `created_by_user_id` apply |
| `asset_versions` | `version_number`, `s3_key`, `asset_short_url`, `asset_long_url`, optional `change_note` |
| `asset_logs` | `activity_type: created` (or `version_added`) with a human-readable message |
| `activity_logs` | written separately by the interceptor, after the response, not in the transaction |
