---
title: Resumable Uploads
path: advanced/resumable-uploads
status: published
---

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. 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
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
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
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
{
  "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
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
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
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
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
curl -H "Authorization: Bearer $SCAIDRIVE_TOKEN" \
     $SCAIDRIVE_URL/api/v1/uploads/ups_01J5T
```

**Response:**

```json
{
  "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
curl -X POST $SCAIDRIVE_URL/api/v1/uploads/ups_01J5T/complete \
  -H "Authorization: Bearer $SCAIDRIVE_TOKEN"
```

**Response:**

```json
{
  "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
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

- [Uploads and Downloads](/docs/scaidrive/api-guides/uploads-and-downloads)
- [Files Guide](/docs/scaidrive/api-guides/files)
- [Versioning and Deduplication](/docs/scaidrive/core-concepts/versioning-and-deduplication)