Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

Resumable Uploads

The upload session protocol. Use it for large files, flaky networks, or any scenario where a multipart POST is too fragile. Clients split the file into fixed-size chunks, upload them in parallel and out of order, then finalize.

Base path: /api/v1/uploads/

Protocol overview#

text
1
2
3
4
5
1. POST /api/v1/uploads                      → session_id, chunk_size, total_chunks
2. PUT  /api/v1/uploads/{session}/chunks/{i} → upload chunk i
   (repeat for each chunk; parallel fine)
3. GET  /api/v1/uploads/{session}            → inspect progress
4. POST /api/v1/uploads/{session}/complete   → finalize into a File

1. Create a session#

bash
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
curl -X POST $SCAIDRIVE_URL/api/v1/uploads \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "share_id": "shr_01J3H",
    "folder_id": "fld_01J3I",
    "file_name": "big-video.mp4",
    "file_size": 2147483648,
    "chunk_size": 4194304,
    "mime_type": "video/mp4",
    "checksum_sha256": "e3b0c44298fc..."
  }'
python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
resp = httpx.post(
    f"{url}/api/v1/uploads",
    headers={"Authorization": f"Bearer {token}"},
    json={
        "share_id": "shr_01J3H",
        "folder_id": "fld_01J3I",
        "file_name": "big-video.mp4",
        "file_size": 2147483648,
        "chunk_size": 4 * 1024 * 1024,
    },
)
session = resp.json()
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
const resp = await fetch(`${url}/api/v1/uploads`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${token}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    share_id: "shr_01J3H",
    folder_id: "fld_01J3I",
    file_name: "big-video.mp4",
    file_size: 2147483648,
    chunk_size: 4 * 1024 * 1024,
  }),
});
const session = await resp.json();

Body fields:

Field Required Notes
share_id Yes
file_name Yes
file_size Yes Exact size in bytes
folder_id No Omit for share root
chunk_size No Server picks a default (typically 4 MB). Must be power of 2 ≥ 64 KB
mime_type No Default from extension
checksum_sha256 No If provided, server verifies on finalize
replace_file_id No Upload a new version of an existing file

Response:

json
1
2
3
4
5
6
7
8
9
{
  "id": "ups_01J5T",
  "file_name": "big-video.mp4",
  "file_size": 2147483648,
  "chunk_size": 4194304,
  "total_chunks": 512,
  "uploaded_chunks": 0,
  "expires_at": "2026-04-24T10:15:00Z"
}

Sessions expire after 24 hours of inactivity (configurable).

2. Upload chunks#

bash
1
2
3
4
curl -X PUT "$SCAIDRIVE_URL/api/v1/uploads/ups_01J5T/chunks/0" \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
  -H "X-Chunk-Sha256: deadbeef..." \
  --data-binary @./chunk_000
python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import hashlib

async def upload_chunk(client, session_id, index, data):
    h = hashlib.sha256(data).hexdigest()
    r = await client.put(
        f"/api/v1/uploads/{session_id}/chunks/{index}",
        headers={"X-Chunk-Sha256": h},
        content=data,
    )
    r.raise_for_status()

async def upload_file(client, session, path, chunk_size):
    with open(path, "rb") as f:
        tasks = []
        index = 0
        while True:
            data = f.read(chunk_size)
            if not data:
                break
            tasks.append(upload_chunk(client, session["id"], index, data))
            if len(tasks) >= 8:
                await asyncio.gather(*tasks)
                tasks = []
            index += 1
        if tasks:
            await asyncio.gather(*tasks)
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
async function uploadChunks(session: any, filePath: string, chunkSize: number) {
  const fd = await open(filePath, "r");
  const buf = Buffer.alloc(chunkSize);
  const tasks: Promise<Response>[] = [];
  let index = 0;
  while (true) {
    const { bytesRead } = await fd.read(buf, 0, chunkSize, null);
    if (!bytesRead) break;
    const data = buf.subarray(0, bytesRead);
    const h = createHash("sha256").update(data).digest("hex");
    tasks.push(
      fetch(`${url}/api/v1/uploads/${session.id}/chunks/${index}`, {
        method: "PUT",
        headers: {
          Authorization: `Bearer ${token}`,
          "X-Chunk-Sha256": h,
        },
        body: data,
      }),
    );
    if (tasks.length >= 8) {
      await Promise.all(tasks);
      tasks.length = 0;
    }
    index++;
  }
  await Promise.all(tasks);
  await fd.close();
}

Optional headers:

Header Effect
X-Chunk-Sha256 Server verifies; mismatch returns CHECKSUM_MISMATCH
Content-Length Required by some HTTP clients; server checks against chunk_size

Response: 204 No Content on success.

Deduplication hint#

Before uploading a chunk, the client can ask whether the server already has it:

bash
1
2
curl -X HEAD "$SCAIDRIVE_URL/api/v1/uploads/ups_01J5T/chunks/0?sha256=deadbeef..." \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"
  • 200 OK — chunk exists, skip the upload. The server registers the chunk against this session.
  • 404 Not Found — chunk doesn't exist, upload normally.

This is what saves bandwidth when re-uploading a file whose content is partly shared with existing content.

3. Inspect progress#

bash
1
2
curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
     $SCAIDRIVE_URL/api/v1/uploads/ups_01J5T

Response:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "id": "ups_01J5T",
  "file_name": "big-video.mp4",
  "file_size": 2147483648,
  "total_chunks": 512,
  "uploaded_chunks": 340,
  "received_chunks": "0,1,2,...,339",
  "expires_at": "2026-04-24T10:15:00Z",
  "completed_at": null
}

Use this to resume after a crash — compare received_chunks to your local state, upload what's missing.

4. Finalize#

When all chunks are uploaded:

bash
1
2
curl -X POST $SCAIDRIVE_URL/api/v1/uploads/ups_01J5T/complete \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"

Response:

json
1
2
3
4
5
6
7
{
  "file_id": "fil_01J5Z",
  "name": "big-video.mp4",
  "size": 2147483648,
  "version_id": "ver_01J5Z",
  "checksum_sha256": "e3b0c44298fc..."
}

Finalize:

  • Reassembles chunks.
  • Verifies the overall checksum_sha256 if supplied on session create.
  • Creates the File row (or new FileVersion if replace_file_id was used).
  • Writes a ChangeLog entry.
  • Deletes the upload session.

Cancel#

bash
1
2
curl -X DELETE $SCAIDRIVE_URL/api/v1/uploads/ups_01J5T \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"

Returns 204. Chunks uploaded but not yet committed to a file are reference-dropped and will be GC'd.

Error handling during upload#

Code HTTP Meaning
UPLOAD_SESSION_NOT_FOUND 404 Session expired or cancelled
UPLOAD_SESSION_EXPIRED 410 Expired during upload; create a new session
CHECKSUM_MISMATCH 400 X-Chunk-Sha256 didn't match received data
VALIDATION_ERROR 422 Chunk index out of range or chunk size wrong
QUOTA_EXCEEDED 507 Quota check happens on finalize; space must be available

The quota check happens at finalize, not on individual chunk uploads. Uploading 10 GB only to discover the user is over quota wastes bandwidth — so POST /api/v1/uploads also does a pre-flight quota check, but the authoritative check is at finalize (usage may have changed during upload).

Parallelism guidance#

  • 4–8 parallel chunk uploads saturate most home broadband.
  • 16+ parallel uploads help only on symmetric 1 Gbit+ links.
  • Larger chunk_size reduces request overhead but increases retransmit cost. 4 MB is a good default; 16 MB for fat pipes.

What's next#

Updated 2026-05-18 15:04:19 View source (.md) rev 2