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
| 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
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..."
}'
|
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()
|
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:
| {
"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
| 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
|
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)
|
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:
| 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
| curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
$SCAIDRIVE_URL/api/v1/uploads/ups_01J5T
|
Response:
| {
"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:
| curl -X POST $SCAIDRIVE_URL/api/v1/uploads/ups_01J5T/complete \
-H "Authorization: Bearer $SCAIDRIVE_TOKEN"
|
Response:
| {
"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
| 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