Files API

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

{
  "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. 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
Larger than 100 MiB, or you want to resume after a dropped connection The large-file flow: create, send parts, complete
Many files at once The bulk option

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
# 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.

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:

{ "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:

{
  "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:

{ "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.

{ "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 ETags 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.

#!/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 ETags 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 ETags and complete with {"hash": "<16 hex>"} instead.

Resume an interrupted upload

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

Returns the parts the server holds:

{ "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 ETags, 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, and each larger file through its own large-file upload, 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:

{ "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
{ "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.