# Upload a File

Upload a file of up to 20 GB to AWS S3, Azure Blob, SharePoint or Google Drive: two requests for a file up to 50 MB, a sample client for bigger files, and what to do when something goes wrong.

> Kind: Guide · Updated: Sep 25, 2026

Upload a file into a storage library connected to your CloudFiles account: create an upload with your API key, then send the file to the upload URL it gives you. Uploads go to AWS S3 (s3), Azure Blob (azure), SharePoint (sharepoint) and Google Drive (google).

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

```bash
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.

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

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

```json
{
  "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&gt;-<last byte&gt;/<file size&gt;. 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.

| Piece | Content-Range | Answer |
|---|---|---|
| 1 | bytes 0-20971519/60000000 | 202 |
| 2 | bytes 20971520-41943039/60000000 | 202 |
| 3 | bytes 41943040-59999999/60000000 | 201 with the file |

With curl, split the file and send each part:

```bash
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):

```json
{
  "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**
```js
// 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**
```py
# 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

| Library | Piece order | Content-MD5 | folderPath | driveId |
|---|---|---|---|---|
| AWS S3 | Any order | Checked | Yes | Optional |
| Azure Blob | Any order | Checked | Yes | Optional |
| SharePoint | In order | Refused | Yes | Required |
| Google Drive | In order | Refused | Not supported | Optional (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

| Limit | Value |
|---|---|
| File size | Up 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 uploads | 50 per account at a time |
| Time | An upload expires after 1 hour without a new piece, and 24 hours after it was created at the latest |

## Questions

**Q: Is there a separate step to finish the upload?**
No. The PUT that delivers the last missing piece answers 201 with the file.

**Q: How do I get a download link for the file?**
The upload's answer carries none. Call Download File with file.id.

## Endpoints

| Request | Page | Use it to |
|---|---|---|
| POST /v1/uploads | Create Upload | Start an upload and get its upload URL. |
| PUT {uploadUrl} | Upload Chunk | Send the file, or one piece of it. |
| GET {uploadUrl} or GET /v1/uploads/{id} | Get Upload | See what is still missing. |
| DELETE {uploadUrl} or DELETE /v1/uploads/{id} | Cancel Upload | Give up on an upload. |
| Any of these | Upload Errors | Look up an error code. |
