# Uploads

> Chunked, resumable uploads for zips and assets up to 10 GB per bucket.

Every upload, small or large, works the same way:

1. **Start** it with the file's `name` and exact `size`: [`POST /v1/buckets/{bucket_id}/uploads`](/api-reference/code-buckets/uploads/create). You get an upload id, `chunk_size` (16,777,216 bytes = 16 MiB) and `chunk_count`.
2. **Send each chunk**: [`PUT /v1/uploads/{upload_id}/chunks/{index}`](/api-reference/code-buckets/uploads/chunk) with the raw bytes. Chunk `i` is bytes `i × chunk_size` to `(i + 1) × chunk_size - 1`; only the last chunk is shorter.
3. **Complete** it: [`POST /v1/uploads/{upload_id}/complete`](/api-reference/code-buckets/uploads/complete).

A file under 16 MiB is one chunk.

## Zips or assets

- A `.zip` is stored as an **archive** and unpacked into the code tree. Upload it with `"target_path": "packages/api"` to replace only that folder.
- Anything else is an **asset**. Set `"kind": "asset"` to store a zip as a plain file instead of unpacking it.

## Parallel chunks

Chunks can go in any order and at the same time. Three or four at once is a good default. Chunk requests don't count toward your key's per-minute limit (they have their own generous limit), but they do count as tokens: 1 per request plus 1 per MiB.

## Checksums

Send `X-Content-SHA256: <hex>` with each chunk and the API refuses it with `400 checksum_mismatch` if the bytes changed on the way. Every response also returns the chunk's `sha256`.

## Resuming

If the connection drops, nothing is lost. Ask which chunks arrived and send the rest:

```bash
curl $CB/v1/uploads/UPLOAD_ID \
  -H "Authorization: Bearer $CLATTER_API_KEY" -H "X-Clatter-Account: $CLATTER_ACCOUNT_ID"
# { "object": "upload", "status": "uploading", "chunk_count": 8, "received_chunks": [0, 1, 2, 5], ... }
```

Sending a chunk twice is safe: the same bytes answer `"duplicate": true`, different bytes replace it.

## A complete uploader

<CodeGroup>
```python Python
import concurrent.futures, hashlib, os, requests

CB = "https://api.codebuckets.clatterlabs.xyz"
H = {"Authorization": f"Bearer {os.environ['CLATTER_API_KEY']}", "X-Clatter-Account": os.environ["CLATTER_ACCOUNT_ID"]}

def upload(bucket_id, path, resume_id=None):
    size = os.path.getsize(path)
    if resume_id:
        up = requests.get(f"{CB}/v1/uploads/{resume_id}", headers=H).json()
    else:
        up = requests.post(f"{CB}/v1/buckets/{bucket_id}/uploads", headers=H, json={"name": os.path.basename(path), "size": size}).json()
    have = set(up.get("received_chunks") or [])

    def send(i):
        with open(path, "rb") as f:
            f.seek(i * up["chunk_size"])
            data = f.read(up["chunk_size"])
        for attempt in range(5):
            r = requests.put(f"{CB}/v1/uploads/{up['id']}/chunks/{i}", data=data,
                             headers={**H, "Content-Type": "application/octet-stream", "X-Content-SHA256": hashlib.sha256(data).hexdigest()})
            if r.ok:
                return
            if r.status_code < 500 and r.status_code != 429:
                raise RuntimeError(r.json()["error"]["message"])
        raise RuntimeError(f"chunk {i} kept failing; resume with upload id {up['id']}")

    with concurrent.futures.ThreadPoolExecutor(4) as pool:
        list(pool.map(send, [i for i in range(up["chunk_count"]) if i not in have]))
    return requests.post(f"{CB}/v1/uploads/{up['id']}/complete", headers=H).json()

print(upload("cbk_...", "storefront.zip"))
```

```javascript JavaScript
import { createHash } from "node:crypto";
import { open, stat } from "node:fs/promises";
import { basename } from "node:path";

const CB = "https://api.codebuckets.clatterlabs.xyz";
const H = { Authorization: `Bearer ${process.env.CLATTER_API_KEY}`, "X-Clatter-Account": process.env.CLATTER_ACCOUNT_ID };

async function upload(bucketId, path, resumeId) {
  const { size } = await stat(path);
  const up = resumeId
    ? await fetch(`${CB}/v1/uploads/${resumeId}`, { headers: H }).then((r) => r.json())
    : await fetch(`${CB}/v1/buckets/${bucketId}/uploads`, { method: "POST", headers: { ...H, "Content-Type": "application/json" }, body: JSON.stringify({ name: basename(path), size }) }).then((r) => r.json());
  const have = new Set(up.received_chunks ?? []);
  const todo = [...Array(up.chunk_count).keys()].filter((i) => !have.has(i));
  const fh = await open(path);
  const send = async () => {
    for (let i; (i = todo.shift()) !== undefined; ) {
      const buf = Buffer.alloc(Math.min(up.chunk_size, size - i * up.chunk_size));
      await fh.read(buf, 0, buf.length, i * up.chunk_size);
      const sha = createHash("sha256").update(buf).digest("hex");
      for (let attempt = 0; ; attempt++) {
        const r = await fetch(`${CB}/v1/uploads/${up.id}/chunks/${i}`, { method: "PUT", headers: { ...H, "Content-Type": "application/octet-stream", "X-Content-SHA256": sha }, body: buf });
        if (r.ok) break;
        if ((r.status < 500 && r.status !== 429) || attempt === 4) throw new Error((await r.json()).error.message);
      }
    }
  };
  await Promise.all([send(), send(), send(), send()]);
  await fh.close();
  return fetch(`${CB}/v1/uploads/${up.id}/complete`, { method: "POST", headers: H }).then((r) => r.json());
}

console.log(await upload("cbk_...", "storefront.zip"));
```
</CodeGroup>

## After completing

- An **asset** is `ready` immediately.
- An **archive** in a bucket with preview on is `processing` while its tree is built (usually a few seconds; longer for very large zips), then `ready`. Poll [Get an upload](/api-reference/code-buckets/uploads/get) or watch for the `code.updated` event.

## Space

Starting an upload reserves its size. If the bucket's stored files plus unfinished uploads plus the new file would pass 10 GB, the start answers `413 bucket_full`. [Abort](/api-reference/code-buckets/uploads/abort) uploads you won't finish to free their space.
