Files up to 50 MB: two requests

1. Create the upload. Send the library, the file name and its exact size in bytes, and ask for 50 MB pieces with chunkSize 52428800. A file of up to 52,428,800 bytes then fits in one piece.

curl --request POST 'https://api.cloudfiles.io/v1/uploads' \
  --header 'Authorization: Bearer API_Key' \
  --header 'Content-Type: application/json' \
  --data '{
    "library": "s3",
    "folderPath": "Contracts/2026",
    "name": "Q3-report.pdf",
    "size": 2621440,
    "chunkSize": 52428800
  }'

2. Send the whole file to the uploadUrl from the answer, here in $UPLOAD_URL. No API key and no Content-Range header: curl sets Content-Length for you.

curl --request PUT "$UPLOAD_URL" --data-binary @Q3-report.pdf

The answer is 201 Created with the new file in file (shortened here):

{
  "id": "upl_66f3c1a2b4d5e6f708192a3b",
  "status": "completed",
  "name": "Q3-report.pdf",
  "size": 2621440,
  "fileId": "66f3c1b9b4d5e6f708192a40",
  "file": {
    "id": "66f3c1b9b4d5e6f708192a40",
    "fullName": "Q3-report.pdf",
    "size": 2621440,
    "path": "Contracts/2026/Q3-report.pdf"
  }
}

Keep file.id: every other file endpoint takes it. The stored name can differ from the one you sent, for example when a free name is chosen or the storage replaces a character it does not allow, so read it from file.fullName.

Bigger files: send it in pieces

A file bigger than one piece goes up as several PUTs to the same uploadUrl, each carrying one piece of the file and a Content-Range header saying which bytes it is. There is still no API key on these requests and no separate call to finish: the PUT that delivers the last missing piece answers with the file.

1
Create the upload
Send POST /v1/uploads as above. Leave chunkSize out to get 20 MB pieces, or set it anywhere from 5 MB to 50 MB. The answer tells you the chunkSize and chunkCount to use, and the uploadUrl.
2
Cut the file into pieces
Every piece is exactly chunkSize bytes, except the last, which is whatever is left. Piece n covers the bytes from (n - 1) × chunkSize to n × chunkSize − 1, counting from 0.
3
PUT each piece
Send the piece's raw bytes as the request body, with Content-Range: bytes <first byte>-<last byte>/<file size>. The number after the slash is always the size of the whole file.
4
Read the answer
202 means the piece is stored and the upload is waiting for more; nextExpectedRanges lists the bytes still missing. 201 means that piece completed the file, and the answer carries it in file.

Example: a 60,000,000-byte file in 20 MB pieces

With the default chunkSize of 20,971,520 bytes, the file is three pieces: two full ones and a last one of 18,056,960 bytes.

PieceContent-RangeAnswer
1bytes 0-20971519/60000000202
2bytes 20971520-41943039/60000000202
3bytes 41943040-59999999/60000000201 with the file

With curl, split the file and send each part:

split -b 20971520 -d site-survey.mp4 part-    # part-00, part-01, part-02

curl --request PUT "$UPLOAD_URL" --header 'Content-Range: bytes 0-20971519/60000000' --data-binary @part-00
curl --request PUT "$UPLOAD_URL" --header 'Content-Range: bytes 20971520-41943039/60000000' --data-binary @part-01
curl --request PUT "$UPLOAD_URL" --header 'Content-Range: bytes 41943040-59999999/60000000' --data-binary @part-02

After the first piece the answer is 202 Accepted, showing what has arrived and what is still to come (shortened here):

{
  "id": "upl_66f3c1a2b4d5e6f708192a3b",
  "status": "uploading",
  "size": 60000000,
  "chunkSize": 20971520,
  "chunkCount": 3,
  "bytesReceived": 20971520,
  "nextExpectedRanges": [
    "20971520-59999999"
  ]
}

Rules for pieces

  • Exact boundaries. A piece starts at a multiple of chunkSize and is exactly chunkSize bytes, except the last. Any other range answers 400 UPLOAD/INVALID_RANGE.
  • Order. SharePoint and Google Drive take pieces in order, one at a time; a piece sent early answers 409 UPLOAD/OUT_OF_ORDER. AWS S3 and Azure Blob take them in any order, up to maxConcurrency (from the create answer) at once.
  • Repeats are safe. Sending a piece that already arrived changes nothing, so after a dropped connection or a 429 or 5xx, send the same piece again.
  • Checksums. On AWS S3 and Azure Blob you can add a Content-MD5 header to have a piece checked; the other libraries refuse it.

Sample client

Both versions create the upload once and never retry that request. They send the pieces in order, which works on every library, and send a piece again after a dropped connection, a 429 or a 5xx, waiting Retry-After seconds when the answer gives them. To resume an interrupted upload, pass its upload URL as a second argument. Set library and folderPath at the bottom to your own; SharePoint also needs driveId.

// Node.js 18 or later. Save as upload.mjs, then run:
//   CLOUDFILES_API_KEY=... node upload.mjs report.pdf          a new upload
//   node upload.mjs report.pdf "$SAVED_UPLOAD_URL"              resume one
import { open, stat } from 'node:fs/promises';
import { basename } from 'node:path';

const API = 'https://api.cloudfiles.io';
const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

// One request to the upload URL. A network error, 429 or 5xx is sent again,
// after Retry-After when the answer has one. Never use this for the create.
async function call(url, init = {}) {
  for (let attempt = 1; ; attempt++) {
    try {
      const res = await fetch(url, init);
      const text = await res.text();
      const result = { status: res.status, body: text ? JSON.parse(text) : {} };
      if ((res.status !== 429 && res.status < 500) || attempt === 6) return result;
      await sleep(Number(res.headers.get('retry-after')) || 2 ** attempt);
    } catch (networkError) {
      if (attempt === 6) throw networkError;
      await sleep(2 ** attempt);
    }
  }
}

function ok({ status, body }) {
  if (status >= 200 && status < 300) return body;
  throw new Error(`${status} ${body.errorCode}: ${body.message}`);
}

// Sends the missing pieces in order until the upload answers with the file.
// `upload` is the create answer, or the answer to a GET on the upload URL.
async function sendPieces(path, uploadUrl, upload) {
  const file = await open(path, 'r');
  try {
    while (!upload.file) { // a 200 or 201 carrying `file` means done
      if (upload.status === 'aborted' || upload.status === 'expired') {
        throw new Error(`The upload is ${upload.status}: create a new one`);
      }
      const [next] = upload.nextExpectedRanges;
      if (!next) { // every piece is in and the file is being put together
        await sleep(2);
        upload = ok(await call(uploadUrl));
        continue;
      }
      const start = Number(next.split('-')[0]);
      const piece = Buffer.alloc(Math.min(upload.chunkSize, upload.size - start));
      await file.read(piece, 0, piece.length, start);
      const res = await call(uploadUrl, {
        method: 'PUT',
        headers: { 'Content-Range': `bytes ${start}-${start + piece.length - 1}/${upload.size}` },
        body: piece, // a Buffer, so fetch sets Content-Length
      });
      upload = res.body.errorCode === 'UPLOAD/OUT_OF_ORDER'
        ? ok(await call(uploadUrl)) // re-read the upload, then carry on
        : ok(res);
    }
    return upload.file;
  } finally {
    await file.close();
  }
}

async function uploadFile(path, destination) {
  const { size } = await stat(path);
  // Sent once, never retried: repeating a create can leave a second upload open.
  const res = await fetch(`${API}/v1/uploads`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CLOUDFILES_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ ...destination, name: basename(path), size }),
  });
  const upload = ok({ status: res.status, body: await res.json().catch(() => ({})) });
  // Save upload.uploadUrl somewhere safe: it is how you resume after a restart.
  return sendPieces(path, upload.uploadUrl, upload);
}

const [path, savedUploadUrl] = process.argv.slice(2);
const file = savedUploadUrl // to resume, GET the upload URL and carry on from there
  ? await sendPieces(path, savedUploadUrl, ok(await call(savedUploadUrl)))
  : await uploadFile(path, { library: 's3', folderPath: 'Contracts/2026' });
console.log('Uploaded', file.id, file.fullName);
# Python 3.8 or later with requests (pip install requests). Run:
#   CLOUDFILES_API_KEY=... python upload.py report.pdf          a new upload
#   python upload.py report.pdf "$SAVED_UPLOAD_URL"              resume one
import os
import sys
import time

import requests

API = "https://api.cloudfiles.io"


def call(method, url, **kwargs):
    # One request to the upload URL. A network error, 429 or 5xx is sent again,
    # after Retry-After when the answer has one. Never use this for the create.
    for attempt in range(1, 7):
        try:
            res = requests.request(method, url, timeout=(10, 900), **kwargs)
        except (requests.ConnectionError, requests.Timeout):
            if attempt == 6:
                raise
            time.sleep(2 ** attempt)
            continue
        if (res.status_code == 429 or res.status_code >= 500) and attempt < 6:
            wait = res.headers.get("Retry-After", "")
            time.sleep(int(wait) if wait.isdigit() else 2 ** attempt)
            continue
        return res


def body_of(res):
    try:
        return res.json()
    except ValueError:
        return {}


def ok(res):
    body = body_of(res)
    if 200 <= res.status_code < 300:
        return body
    raise RuntimeError(f"{res.status_code} {body.get('errorCode')}: {body.get('message')}")


def send_pieces(path, upload_url, upload):
    # Sends the missing pieces in order until the upload answers with the file.
    # `upload` is the create answer, or the answer to a GET on the upload URL.
    with open(path, "rb") as f:
        while "file" not in upload:  # a 200 or 201 carrying "file" means done
            if upload["status"] in ("aborted", "expired"):
                raise RuntimeError(f"The upload is {upload['status']}: create a new one")
            if not upload["nextExpectedRanges"]:
                time.sleep(2)  # every piece is in and the file is being put together
                upload = ok(call("GET", upload_url))
                continue
            start = int(upload["nextExpectedRanges"][0].split("-")[0])
            f.seek(start)
            piece = f.read(upload["chunkSize"])
            content_range = f"bytes {start}-{start + len(piece) - 1}/{upload['size']}"
            res = call("PUT", upload_url, data=piece, headers={"Content-Range": content_range})
            if body_of(res).get("errorCode") == "UPLOAD/OUT_OF_ORDER":
                upload = ok(call("GET", upload_url))  # re-read the upload, then carry on
            else:
                upload = ok(res)
    return upload["file"]


def upload_file(path, **destination):
    # Sent once, never retried: repeating a create can leave a second upload open.
    upload = ok(requests.post(
        f"{API}/v1/uploads", timeout=(10, 300),
        headers={"Authorization": f"Bearer {os.environ['CLOUDFILES_API_KEY']}"},
        json={**destination, "name": os.path.basename(path), "size": os.path.getsize(path)},
    ))
    # Save upload["uploadUrl"] somewhere safe: it is how you resume after a restart.
    return send_pieces(path, upload["uploadUrl"], upload)


if __name__ == "__main__":
    if len(sys.argv) > 2:  # to resume, GET the upload URL and carry on from there
        uploaded = send_pieces(sys.argv[1], sys.argv[2], ok(call("GET", sys.argv[2])))
    else:
        uploaded = upload_file(sys.argv[1], library="s3", folderPath="Contracts/2026")
    print("Uploaded", uploaded["id"], uploaded["fullName"])

If something goes wrong

  • The connection dropped: send the same piece again. Sending a piece twice is safe.
  • Your program restarted: GET the upload URL (Get Upload). nextExpectedRanges lists the bytes still missing; carry on from the first one.
  • You want to give up: DELETE the upload URL (Cancel Upload).

Keep the uploadUrl: it is the only way back to an upload, and the create answer is the only place it appears. Treat it as a secret, because anyone who has it can send to, read or cancel that upload without an API key. For a specific error, see Upload Errors.

Where libraries differ

LibraryPiece orderContent-MD5folderPathdriveId
AWS S3Any orderCheckedYesOptional
Azure BlobAny orderCheckedYesOptional
SharePointIn orderRefusedYesRequired
Google DriveIn orderRefusedNot supportedOptional (a shared drive)

conflictBehavior, for when a file of the same name exists, takes fail, rename or replace; noop is refused. Google Drive keeps both files and takes no conflictBehavior.

Limits

LimitValue
File sizeUp to 20 GB (21,474,836,480 bytes)
Piece size (chunkSize)5 MB to 50 MB (5,242,880 to 52,428,800 bytes); 20 MB by default
Open uploads50 per account at a time
TimeAn upload expires after 1 hour without a new piece, and 24 hours after it was created at the latest

Questions

Is there a separate step to finish the upload?

No. The PUT that delivers the last missing piece answers 201 with the file.

How do I get a download link for the file?

The upload's answer carries none. Call Download File with file.id.

Endpoints

RequestPageUse it to
POST /v1/uploadsCreate UploadStart an upload and get its upload URL.
PUT {uploadUrl}Upload ChunkSend the file, or one piece of it.
GET {uploadUrl} or GET /v1/uploads/{id}Get UploadSee what is still missing.
DELETE {uploadUrl} or DELETE /v1/uploads/{id}Cancel UploadGive up on an upload.
Any of theseUpload ErrorsLook up an error code.