# Files API

> Fetch a filesystem tree, upload files of any size in one call or in resumable parts, mint signed download URLs and delete nodes, with the request and response shapes.

## Get File Tree

```
GET /files/fs/:fsUid/tree
GET /files/fs/:fsUid/tree/:uid
```

The first returns the top level of a filesystem; the second returns one folder, identified by its node uid, and its direct children. Both read one level at a time: to go deeper, call the second route with a folder uid from the response. Reading needs a key with `files:read`.

### Query Parameters

| Parameter | Type | Description |
|---|---|---|
| `limit` | number | Children to return, 1 to 10,000. Defaults to 10,000 |
| `after` | string | The `nextCursor` of the previous page, to continue a folder listing from where it stopped |

### Response

```json
{
  "root": "__root__",
  "nodes": {
    "__root__": {
      "uid": "__root__",
      "name": "",
      "type": "folder",
      "hash": "",
      "parentUid": null,
      "account": "acc_us_abc123",
      "created": 0,
      "modified": 0,
      "status": "ready",
      "size": 1048576
    },
    "node_abc123": {
      "uid": "node_abc123",
      "name": "document.pdf",
      "type": "file",
      "hash": "3d9a6f1e8c27b450",
      "parentUid": null,
      "account": "acc_us_abc123",
      "created": 1781512200,
      "modified": 1781512200,
      "status": "ready",
      "extension": "pdf",
      "contentType": "application/pdf",
      "size": 1048576,
      "versionUid": "ver_abc123"
    }
  },
  "nextCursor": "document.pdf"
}
```

- `nodes` is an object keyed by node uid, not an array. `root` is the uid of the node you asked for. When it is a folder, `nodes` holds that folder plus one page of its direct children, in name order, each with a `parentUid` naming the folder; when it is a file, `nodes` holds only that file.
- For the top level, `root` is `__root__`, a virtual folder standing for the filesystem itself. Top-level nodes have a `parentUid` of `null`.
- A file's `hash` is its CRC-64/NVME (the checksum S3 and aws-cli call CRC64NVME) as 16 lowercase hex characters of the 64-bit value, with no prefix. It is `""` for a folder, and for a file whose first upload has not completed.
- `status` is `ready`, or `pending` / `pending-update` while an upload to the node is in progress. A pending file cannot be downloaded yet.
- `created` and `modified` are Unix timestamps in seconds.
- `nextCursor` is present only when the folder has more children than this page returned. Pass it back as `after`, with the same route and folder, to get the next page; the last page has no `nextCursor`.

Asking the second route for a uid that does not exist answers `404 not_found`. To fetch the whole tree in one call, the API reference lists `Get Full Tree`.

## Upload Files

Every upload route lives under `/files/fs/:fsUid`, where `:fsUid` is the filesystem's uid from the [Filesystems API](/docs/api/filesystems.md). Uploading needs a key with `files:create`; replacing a file that already exists also needs `files:update`.

| Your file | Use |
|---|---|
| Up to 100 MiB | [One call](#upload-in-one-call) |
| Larger than 100 MiB, or you want to resume after a dropped connection | [The large-file flow](#upload-a-large-file): create, send parts, complete |
| Many files at once | [The bulk option](#upload-many-files) |

Storage is counted across every filesystem on the account. Uploading in one call, creating an upload (single or in bulk) and a batch upload are refused with `422 storage_quota_exceeded` when the sizes the call declares exceed the account's storage limit. Once the account is over its limit, they are refused within a few minutes, except a call whose files are all empty, which stores nothing. In between, usage counts finished uploads only and can trail your recent uploads and deletions by a few minutes, so an account can end up over its limit by what it uploads in that time, and after deleting files it can take a few minutes before uploads are accepted again. Parts and completes of an upload you already created are not refused for storage.

### Upload in one call

```
PUT /files/fs/:fsUid/upload/file
```

Send the raw file bytes as the request body. Returns `201` with the new file node.

| Header | Required | Description |
|---|---|---|
| `X-Hyperfile-Path` | Yes | Destination path, e.g. `/reports/q3.pdf`, percent-encoded as UTF-8 (see below) |
| `X-Hyperfile-Size` | Yes | File size in bytes. A body of any other length is refused with `422 validation` and nothing is kept |
| `X-Hyperfile-Hash` | No | CRC-64/NVME of the file as 16 hex characters. When sent, it is recorded as the file's hash exactly as you sent it; the server does not check it against the bytes, and repeating the upload is safe. When omitted, the server computes it while storing the file |
| `X-Hyperfile-If-Match` | No | The hash you believe the file at this path currently holds. If the file has changed since, or another upload to the path is in progress, the upload is refused with `409 conflict` before anything is stored |
| `Content-Type` | No | Stored as the file's content type. Defaults to `application/octet-stream` |

```bash
# The filesystem to upload into: the `uid` of an entry in `account.filesystems` from GET /account/me
FS_UID="<your filesystem uid>"

curl -X PUT "https://api.hyperfile.io/files/fs/$FS_UID/upload/file" \
  -H "Authorization: Bearer $HYPERFILE_API_KEY" \
  -H "X-Hyperfile-Path: /reports/q3.pdf" \
  -H "X-Hyperfile-Size: $(wc -c < q3.pdf)" \
  -H "Content-Type: application/pdf" \
  --data-binary @q3.pdf
```

A header value cannot carry characters such as `日本`, so `X-Hyperfile-Path` is percent-encoded as UTF-8: encode each segment as `encodeURIComponent` does and keep the `/` between segments. `/photos/日本.jpg` is sent as `/photos/%E6%97%A5%E6%9C%AC.jpg`, and a `%` in a name as `%25`. The server decodes the header once, so encode it once. A value that is not valid percent-encoding is refused with `400`.

Sending `X-Hyperfile-Hash` saves the server hashing the file, so send it when you already have it. If you have no tool that computes CRC-64/NVME, leave it out: the upload works the same, and the server computes the hash as it stores the file. The Hyperfile CLI prints a file's hash with `hyperfile hash <file>`. The hash must be of exactly the bytes you upload: a wrong one is stored as the file's hash, and anything that compares hashes, sync clients included, is then misled about the file. With a hash, the request must also carry a `Content-Length` equal to `X-Hyperfile-Size`, or it is refused with `422 validation` before anything is stored; `--data-binary @file` sends one. Without a hash, a streamed body with no `Content-Length` is accepted. For a `multipart/form-data` upload with a hash, the `file` part must be `X-Hyperfile-Size` bytes.

With a hash, an upload is safe to repeat. If the file at the path already holds exactly that hash and size — say your first attempt succeeded but its response was lost — the call answers `201` with that file and stores nothing, even where `X-Hyperfile-If-Match` or a missing `files:update` would otherwise refuse it. This applies to any caller with `files:create`, so a caller who guesses a file's exact hash and size learns that the path holds it; that is accepted, because such a caller may already write to the filesystem. The storage check still comes first, so a repeat from an account over its limit is refused with `422 storage_quota_exceeded` (unless it declares 0 bytes). Without a hash, a repeat stores the bytes again as a new version of the file.

The limit is 100 MiB (104,857,600 bytes). A larger `X-Hyperfile-Size` is refused with `413 payload_too_large` before anything is stored, and the message names the large-file create route, `POST /files/fs/:fsUid/uploads`; use the large-file flow instead. A `multipart/form-data` body with a single `file` part is also accepted, for browser forms, with a lower limit of 50 MiB.

### Upload a large file

Three steps: create the upload, send the file in numbered parts, then complete it with the file's hash — or, if you cannot compute CRC-64/NVME, let the server compute it from the parts (see `serverHash` below). This flow accepts a file of any size, and a file up to 100 MiB gets a plan of one part, but such a file is quicker to send [in one call](#upload-in-one-call).

```
POST   /files/fs/:fsUid/uploads
PUT    /files/fs/:fsUid/uploads/:uploadId/parts/:n
POST   /files/fs/:fsUid/uploads/:uploadId/complete
```

**1. Create the upload.** Send the destination path and the file size:

```json
{ "path": "/videos/launch.mp4", "size": 2147483648, "contentType": "video/mp4" }
```

`contentType` is optional, up to 255 printable ASCII characters (space through `~`), for example `text/plain; charset=utf-8`. So is `serverHash`: send `true` when you cannot compute the whole file's CRC-64/NVME yourself, and the server hashes each part as it arrives instead. So is `partSize`, in bytes, between 5 MiB and 100 MiB; the default is 20 MiB. One upload takes at most 10,000 parts, so for a file over about 210 GB send a `partSize` of at least the file size divided by 10,000, rounded up to a whole MiB. At 100 MiB parts one upload carries up to about 1,049 GB. A plan that would need more than 10,000 parts is refused with `422 validation` before anything is registered. The response is `201`:

```json
{
  "uploadId": "eyJhbGciOi...",
  "uid": "node_abc123",
  "partSize": 20971520,
  "partCount": 103,
  "expiresAt": 1790467200
}
```

- `uploadId` identifies this upload in every later call. Use it exactly as returned and do not try to read anything out of it. It is three dot-separated runs of letters, digits, `-` and `_`, at most 2,048 characters in all, so it goes into the URL path as is, with no encoding.
- `partCount` is the number of parts to send: the size divided by `partSize`, rounded up, and at least 1.
- `uid` is the file node the upload will become. No later call needs it.

If storage fails after the file is accepted, the call answers `502` with no upload id and undoes what it registered, so there is nothing to cancel — retry the call.
- `expiresAt` is when the upload id stops working, as a Unix timestamp in seconds — 24 hours after creation. Complete the upload before then.

**2. Send the parts.** Send parts `1` to `partCount`. Part `n` is the bytes of the file from `(n - 1) × partSize` onward. Lengths are exact: every part is exactly `partSize` bytes except the last, which is exactly what remains, and a part of any other length is refused with `422 validation`. Each part answers `204` with no body. For an upload created with `serverHash`, the response also carries an `ETag` header: the part's CRC-64/NVME, 16 hex characters in double quotes. Keep the `ETag` from the last send of each part for the complete call. If you already have a part's CRC-64/NVME, send it in `X-Hyperfile-Hash` and the server records it as sent and answers it as the `ETag`, without hashing the part itself. Parts can go in any order and several at a time, and until the upload is completed a part can be sent again — the last copy of a part number wins. An empty file is one part with an empty body.

**3. Complete the upload.** Send the CRC-64/NVME of the whole file as 16 hex characters, and optionally the `contentType` (up to 255 printable ASCII characters) of what you sent:

```json
{ "hash": "ae8b14860a799888" }
```

For an upload created with `serverHash`, send `parts` in place of `hash`: one entry for every part from `1` to `partCount`, each with the `ETag` that part answered, quoted or bare, in any order. The server combines them into the whole file's hash. A body that fails request validation — a `hash` that is not 16 hex characters, an empty `parts` list or one of over 10,000 entries, a `partNumber` that is not a whole number from 1 to 10,000, or an `etag` over 64 characters — is refused with `400`. Past that, sending `hash` for such an upload, `parts` for any other or neither, a list that misses or repeats a part or names one beyond `partCount`, or an `etag` that is not 16 hex characters, is refused with `422 validation`.

```json
{ "parts": [{ "partNumber": 1, "etag": "1f0c2d6a9b3e4c57" }, { "partNumber": 2, "etag": "8d41e7a0c25f9b36" }] }
```

The server checks that every part arrived and that their sizes add up to the declared size, joins them into the file, and returns `201` with the file node. If a part is missing or the sizes do not add up, it answers `422 validation` and nothing is lost: send the parts again (the status route below lists what arrived) and complete again. Apart from the `ETag`s of a `serverHash` upload, you keep no per-part receipts; the server has what it needs.

Completing is safe to repeat. If a complete call's response is lost, send the same call again: an upload that already completed answers `201` with the same file node when you send the same `hash` (or `parts` that combine to it), and `422 validation` when you send a different one. A `410 gone` means there is nothing left to complete — the upload was cancelled, expired, or superseded by a newer version of the same file — so create a new upload.

#### Worked example

This bash script uploads one large file with `curl`, `dd` and `jq`, and lets the server compute the file's hash, so no hashing tool is needed. Save it as a file, say `upload.sh`, and run it with `bash upload.sh` rather than pasting it into a terminal: it ends with `exit` on any failure, which would close an interactive shell. On macOS, use `stat -f %z` for the size.

```bash
#!/usr/bin/env bash
set -euo pipefail

API="https://api.hyperfile.io"
# The filesystem to upload into: the `uid` of an entry in `account.filesystems` from GET /account/me
FS_UID="<your filesystem uid>"
AUTH="Authorization: Bearer $HYPERFILE_API_KEY"
FILE="launch.mp4"
SIZE=$(stat -c %s "$FILE")

# At most 10,000 parts: size / 10,000 rounded up to a whole MiB, never under the 20 MiB default
# and never over the 100 MiB maximum, which caps one upload at about 1,049 GB
MIB=1048576
NEEDED=$(( (SIZE + 10000 * MIB - 1) / (10000 * MIB) * MIB ))
if (( NEEDED > 100 * MIB )); then
  echo "$FILE needs more than 10,000 parts of 100 MiB, over the most one upload carries" >&2
  exit 1
fi
PART_SIZE=$(( NEEDED > 20 * MIB ? NEEDED : 20 * MIB ))

# 1. Create the upload, asking the server to hash the parts
PLAN=$(curl -sS --fail -X POST "$API/files/fs/$FS_UID/uploads" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"path\": \"/videos/$FILE\", \"size\": $SIZE, \"partSize\": $PART_SIZE, \"serverHash\": true}")
UPLOAD_ID=$(echo "$PLAN" | jq -r .uploadId)
PART_SIZE=$(echo "$PLAN" | jq -r .partSize)
PART_COUNT=$(echo "$PLAN" | jq -r .partCount)

# 2. Send parts 1..partCount, keeping each part's ETag
ETAGS=$(mktemp)
HEADERS=$(mktemp)
trap 'rm -f "$ETAGS" "$HEADERS"' EXIT
for n in $(seq 1 "$PART_COUNT"); do
  dd if="$FILE" bs="$PART_SIZE" skip=$((n - 1)) count=1 2>/dev/null |
    curl -sS --fail -X PUT "$API/files/fs/$FS_UID/uploads/$UPLOAD_ID/parts/$n" \
      -H "$AUTH" -H "Content-Type: application/octet-stream" \
      --data-binary @- -D "$HEADERS" -o /dev/null ||
    { echo "part $n failed" >&2; exit 1; }
  ETAG=$(tr -d '\r"' < "$HEADERS" | sed -n 's/^[Ee][Tt][Aa][Gg]: *//p' | tail -n 1)
  [ -n "$ETAG" ] || { echo "part $n answered no ETag" >&2; exit 1; }
  echo "$n $ETAG" >> "$ETAGS"
done

# 3. Complete with the ETags; the server combines them into the file's hash
jq -R -s '{parts: [split("\n")[] | select(length > 0) | split(" ") | {partNumber: (.[0] | tonumber), etag: .[1]}]}' "$ETAGS" |
  curl -sS --fail -X POST "$API/files/fs/$FS_UID/uploads/$UPLOAD_ID/complete" \
    -H "$AUTH" -H "Content-Type: application/json" \
    --data-binary @-
```

`--data-binary @-` reads the part into memory and sends it with a `Content-Length`, which every part needs, and `-D` writes the response headers, where the `ETag` is, to a file. `set -euo pipefail` stops the script at the first command that fails, the create and complete calls included, and a part fails the loop on curl's own exit status — `--fail` turns an error response into one — as well as on a missing `ETag`. The `trap` removes both temporary files however the script ends. The `ETag`s go to the complete call through a file and a pipe rather than one command-line argument, since the list for thousands of parts is longer than a shell allows in one argument. The loop sends one part at a time; to go faster, send several parts in parallel. If you can compute CRC-64/NVME yourself, leave `serverHash` out, ignore the `ETag`s and complete with `{"hash": "<16 hex>"}` instead.

#### Resume an interrupted upload

```
GET /files/fs/:fsUid/uploads/:uploadId
```

Returns the parts the server holds:

```json
{ "partCount": 103, "received": [1, 2, 3, 5, 6] }
```

Send every part number from `1` to `partCount` that is missing from `received` — part 4 here — then complete as usual. This works until the upload's `expiresAt`. `received` carries no `ETag`s, so for a `serverHash` upload keep the ones you collected as you go; a part whose `ETag` you lost can simply be sent again, and its new answer used.

#### Cancel an upload

```
DELETE /files/fs/:fsUid/uploads/:uploadId
```

Discards the parts sent so far. A new file's placeholder node is removed; a file the upload was replacing keeps its current content. Returns `204`.

Cancelling is safe at any time, including after a complete call whose response you never saw: it never removes a file that has already completed. Cancel every upload you give up on. One that is neither completed nor cancelled expires after 24 hours, and the parts of an unfinished upload with more than one part are discarded 2 days after it started.

#### When an upload id is refused

An upload id that is not the shape described above — longer than 2,048 characters, or not three dot-separated runs of letters, digits, `-` and `_` — fails request validation with `400`. Use the id exactly as returned. A part, status, complete or cancel call with an upload id of the right shape that is invalid, revoked or past `expiresAt` answers `401` with code `invalid_token`. That code is about the upload id, not your API key: refreshing your credentials will not help. Create a new upload and send the file again.

A part sent to an upload that was completed or cancelled answers `410 gone`, and nothing is stored. For an upload with one part, so does a part sent after a newer upload of the same path replaced it.

Complete answers `410 gone` when the upload was cancelled, expired, or superseded by a newer version of the same file. Repeating a complete that already succeeded is not a `410`: it answers `201` with the same file node, as described above.

An upload with more than one part that was cancelled answers `410 gone` on status; one that was already completed answers `200` with every part listed in `received`. An upload with one part never answers `410` on status: status answers `200`, with `received: []` while the part is not stored. Its complete answers `422 validation` while the part is not stored — send it, then complete again — and `410 gone` only once the upload was cancelled, expired or superseded.

Do not retry a `410`: the upload no longer exists, so create a new one. On part, status and complete a `502 upstream` is different: the storage service failed, not the upload, so retry the call.

Creating uploads and sending parts need an active subscription. Completing, checking and cancelling an upload do not, so an upload you already started can always be finished or cleaned up.

### Upload many files

Send each file up to 100 MiB [in one call](#upload-in-one-call), and each larger file through its own [large-file upload](#upload-a-large-file), several at a time.

**Send many files in one request.** Files adding up to 100 MiB, less the manifest, can share one request; the API reference lists that route as `Upload Batch`. Its manifest holds two kinds of entry: a pack, an archive of files of about 100 KB or less stored together, and a file entry, one file stored on its own. The web app and CLI send a file this way when it is at most two of their part sizes (or their request bound, where that is smaller), and a larger file in parts.

A batch is safe to repeat, because every file in it carries its hash. A file whose path already holds exactly that hash and size — say your first attempt succeeded but its response was lost — is answered with that file and not written again, even where a missing `files:update` would otherwise refuse it. The other files in the batch are written as usual, and the response still lists every file. As with a single upload, this applies to any caller with `files:create`, so a caller who guesses a file's exact hash and size learns that the path holds it; that is accepted, because such a caller may already write to the filesystem. The storage check still comes first.

## Download a File

Downloading takes two calls: mint a signed download token, then fetch the bytes with it.

```
POST /files/fs/:fsUid/file/download-tokens
```

Send up to 100 node uids as `{ "uids": [...] }`. The response carries a short-lived token per file under `tokens`; a file that is still uploading or is not in this filesystem is listed under `deferred` instead.

```
GET /files/__signed-download/:uid?token=...
```

Returns the raw file bytes. The token in the query string is the only credential, so the URL works directly in a browser or as an `<img>` or `<video>` source. Supports HTTP `Range` requests, and `disposition=attachment` or `disposition=inline` to choose how a browser treats the file.

## Delete File

```
DELETE /files/fs/:fsUid/file/:uid
```

Removes a file or folder by node uid; deleting a folder removes everything under it. Needs a key with `files:delete`. The response echoes only the uid you asked for, not the descendants that went with it:

```json
{ "deleted": ["node_abc123"] }
```

A uid that does not exist answers `404 not_found`.

To delete many nodes in one request:

```
DELETE /files/fs/:fsUid/batch
```

```json
{ "uids": ["node_abc123", "node_def456"] }
```

Send 1 to 10,000 uids. The response is `{ "deleted": [...] }`, listing the requested uids that existed, without duplicates; folders still take their descendants with them, but only the uids you sent are listed. A uid missing from `deleted` was already gone, which is not an error.

---

Canonical HTML version: https://hyperfile.io/docs/api/files/
