Upload sessions
Large files never travel through the control plane. An upload session brackets a
batch of objects: the API authorizes them, your client PUTs the bytes straight to
object storage, and finalize promotes them into the repository.
POST …/uploads → sessionPOST …/uploads/{sessionID}/objects → presigned PUT per objectPUT <upload_url> → your client → object storagePOST …/uploads/{sessionID}/objects/{oid}/completePOST …/uploads/{sessionID}/finalize → promote, bump generation, emit eventsexport API=https://cavsnode.com/api/v1export REPO=<repository-uuid>export H="Authorization: Bearer $CAVS_TOKEN"export J="Content-Type: application/json"All five endpoints require repository.push (repo:write scope).
POST /repositories/{repoID}/uploads
Section titled “POST /repositories/{repoID}/uploads”Open a session.
curl -sS -X POST "$API/repositories/$REPO/uploads" \ -H "$H" -H "$J" -H "Idempotency-Key: upload-$OID" \ -d '{"expected_objects": 3}'| Field | Type | Notes |
|---|---|---|
expected_objects |
int | Advisory count. Optional. |
Headers: Idempotency-Key — strongly recommended. The same key returns the same
session instead of creating a second one.
{ "session": { "id": "3b1f8a2c-…", "repository_id": "0f8c…", "expected_objects": 3, "uploaded_objects": 0, "uploaded_bytes": 0, "status": "OPEN", "created_at": "2026-07-28T12:00:00Z", "expires_at": "2026-07-28T18:00:00Z" }}Sessions live 6 hours. Emits upload.session.created.
POST /repositories/{repoID}/uploads/{sessionID}/objects
Section titled “POST /repositories/{repoID}/uploads/{sessionID}/objects”Authorize objects and get a presigned PUT URL for each. Batch freely.
curl -sS -X POST "$API/repositories/$REPO/uploads/$SESSION/objects" \ -H "$H" -H "$J" \ -d '{"objects": [ {"oid": "9f2c8b0e…", "size": 104857600}, {"oid": "aa11bb22…", "size": 52428800} ]}'| Field | Type | Notes |
|---|---|---|
objects[].oid |
string | Lowercase hex SHA-256 of the object’s bytes, 64 chars |
objects[].size |
int64 | Exact byte length |
{ "objects": [ { "oid": "9f2c8b0e…", "upload_url": "https://<object-storage>/…?X-Amz-Signature=…", "storage_key": "org/…/repo/…/objects/9f/2c/9f2c8b0e…", "expires_in": 900 } ]}PUT <upload_url>
Section titled “PUT <upload_url>”Send the raw bytes to object storage. The API is not in this path.
curl -sS --fail-with-body -X PUT "$URL" --upload-file ./model.safetensors- Set
Content-Length(curl does). - No
Authorizationheader — the signature is the credential. - Retriable: on a network error or
5xx, rewind and resend. - URLs expire in 15 minutes. If one lapses, re-request it from step 2.
POST /repositories/{repoID}/uploads/{sessionID}/objects/{oid}/complete
Section titled “POST /repositories/{repoID}/uploads/{sessionID}/objects/{oid}/complete”Record that the object landed. This meters usage.
curl -sS -X POST \ "$API/repositories/$REPO/uploads/$SESSION/objects/$OID/complete" \ -H "$H" -H "$J" -d '{"size": 104857600}'Returns 204 No Content. Idempotent.
POST /repositories/{repoID}/uploads/{sessionID}/finalize
Section titled “POST /repositories/{repoID}/uploads/{sessionID}/finalize”Promote the objects into the repository, bump the generation, append to the usage ledger and emit events.
curl -sS -X POST "$API/repositories/$REPO/uploads/$SESSION/finalize" \ -H "$H" -H "Idempotency-Key: upload-$OID"{ "session": { "id": "3b1f8a2c-…", "status": "FINALIZED", "uploaded_objects": 3, "uploaded_bytes": 209715200 }, "generation": 42}Emits upload.session.finalized and repository.usage.changed, and audits
repository.push.
Resuming and retrying
Section titled “Resuming and retrying”| Situation | What to do |
|---|---|
A PUT failed mid-transfer |
Rewind and re-PUT the same URL, or re-authorize if it expired |
| The process died after some objects landed | Re-open with the same Idempotency-Key, re-authorize the remaining objects, complete, finalize |
| Finalize returned a network error | Just call it again — it’s idempotent |
| A session was abandoned | It expires in 6 hours and its objects are swept. Start fresh. |
Full worked example
Section titled “Full worked example”-
Terminal window FILE=./model.safetensorsOID=$(sha256sum "$FILE" | cut -d' ' -f1)SIZE=$(wc -c < "$FILE" | tr -d ' ')KEY="upload-$OID" -
Terminal window SESSION=$(curl -sS --fail-with-body -X POST "$API/repositories/$REPO/uploads" \-H "$H" -H "$J" -H "Idempotency-Key: $KEY" \-d '{"expected_objects": 1}' | jq -r '.session.id') -
Terminal window URL=$(curl -sS --fail-with-body -X POST \"$API/repositories/$REPO/uploads/$SESSION/objects" \-H "$H" -H "$J" \-d "{\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" \| jq -r '.objects[0].upload_url') -
Terminal window curl -sS --fail-with-body -X PUT "$URL" --upload-file "$FILE" -
Terminal window curl -sS --fail-with-body -X POST \"$API/repositories/$REPO/uploads/$SESSION/objects/$OID/complete" \-H "$H" -H "$J" -d "{\"size\": $SIZE}" -
Terminal window curl -sS --fail-with-body -X POST \"$API/repositories/$REPO/uploads/$SESSION/finalize" \-H "$H" -H "Idempotency-Key: $KEY"
Storage layout
Section titled “Storage layout”Objects land at:
<storage_namespace>/objects/<oid[0:2]>/<oid[2:4]>/<oid>The two-level shard keeps any single prefix from getting hot. Because the key derives from the content hash, writing the same bytes twice is the same key.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Session lifetime | 6 hours |
| Presigned URL lifetime | 15 minutes |
| Max object size | plan-dependent: 5 GiB (Free) → 1 TiB (Business) |
| Concurrent uploads | plan-dependent: 2 → 64 |
| Request body | 1 MiB — batch a few hundred objects per authorize call, not tens of thousands |
| API rate limit | 30 req/s, burst 60 |
- Downloads — the other direction.
- Plain HTTP — a complete client implementation.
- Errors — status codes and retries.

