Upload large files
A file can go into a single request of POST /v1/cards up to the upload limit of the
installation (200 MB by default). Above that, or on a network where a long request is likely to break, upload the
file in parts:
- Create an upload: tell Sursa the name, type and size of the file.
- Send the parts, in any order and in parallel if you like. A failed part is sent again on its own.
- Complete the upload with the SHA-256 of the whole file. Sursa assembles the file and checks it.
- Use the upload ID in place of the file: for a card, an archive, or a document of a custom source.
| Limit | Value |
|---|---|
| File size | Up to 5 GB |
| Part size | Chosen by Sursa in part_size, from 5 MiB to 100 MiB; the last part can be smaller |
| Number of parts | Up to 1000 |
| Time to complete | 24 hours after the upload is created |
| Time to use a completed upload | 24 hours |
1. Create the upload
POST /v1/uploads takes the file's name, type and size, and what you are going
to use it for:
FILE=training-video-transcripts.zip
SIZE=$(wc -c < "$FILE" | tr -d ' ')
curl -X POST https://api.example.com/v1/uploads \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d "{\"filename\": \"$FILE\", \"content_type\": \"application/zip\", \"size\": $SIZE, \"purpose\": \"archive\"}"{
"id": "upl_5d3c2b1a0f9e8d7c6b5a493827161504",
"status": "pending",
"part_size": 8388608,
"part_count": 23,
"expires_at": "2026-10-25T10:00:00Z"
}
purpose | The upload will be used for |
|---|---|
card_file | A card: POST /v1/cards. |
archive | An archive of documents: POST /v1/cards/archive. |
import_attachment | A file of a document of a custom source, in upload_id or attachments. |
The key needs the scope of the place the upload goes to: cards:write for card_file and archive, import:write
for import_attachment.
Sursa chooses part_size and tells you how many parts to send. If you know the SHA-256 of the file in advance, you
may pass it as sha256 here too.
2. Send the parts
Cut the file into part_count pieces of part_size bytes (the last one is the rest) and send each with
PUT /v1/uploads/{upload_id}/parts/{n}. Parts are numbered from 1. The body is the
raw bytes of the part.
UPLOAD=upl_5d3c2b1a0f9e8d7c6b5a493827161504
split -b 8388608 -a 4 "$FILE" part-
n=1
for part in part-*; do
curl -X PUT "https://api.example.com/v1/uploads/$UPLOAD/parts/$n" \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$part" --retry 5 --fail
n=$((n + 1))
doneSending a part again replaces it, so a part that failed or timed out is simply sent again. Up to 4 parts in parallel usually saturate a fast connection.
Check the parts
Every part goes through the API: there are no direct links to the storage. The response to a part has its ETag
header, the SHA-256 of the part in quotes, and the same value in etag of the JSON body.
GET /v1/uploads/{upload_id} lists the parts received so far in parts, so after a
break you send only the missing ones.
3. Complete the upload
POST /v1/uploads/{upload_id}/complete takes the SHA-256 of the whole file
as 64 hexadecimal characters and, optionally, the etag of each part in parts, to check the parts too:
SHA=$(shasum -a 256 "$FILE" | cut -d' ' -f1)
curl -X POST "https://api.example.com/v1/uploads/$UPLOAD/complete" \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Idempotency-Key: complete-$UPLOAD" \
-H "Content-Type: application/json" \
-d "{\"sha256\": \"$SHA\"}"Sursa assembles the parts, compares the hash, and checks the file as it checks any upload: its format and, where it
is enabled, antivirus. The answer is 200 when the check is done and 202 while it still runs. The status of the
upload goes from processing to ready, or to rejected with the reason in reject_reason. Read it with
GET /v1/uploads/{upload_id}.
4. Use the upload
Pass upload_id where you would send the file. Each upload is used once: after it, its status is consumed.
For a card and an archive, send upload_id in a JSON body instead of the multipart/form-data form. This body takes
the collection by its name in collection.
# A card from the uploaded file
curl -X POST https://api.example.com/v1/cards \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Idempotency-Key: card-from-$UPLOAD" \
-H "Content-Type: application/json" \
-d "{\"upload_id\": \"$UPLOAD\", \"collection\": \"support\", \"async\": true}"
# An archive of documents from the uploaded ZIP
curl -X POST https://api.example.com/v1/cards/archive \
-H "Authorization: Bearer $SURSA_API_KEY" \
-H "Idempotency-Key: archive-from-$UPLOAD" \
-H "Content-Type: application/json" \
-d "{\"upload_id\": \"$UPLOAD\", \"collection\": \"support\"}"A complete uploader
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { open, stat } from "node:fs/promises";
import { basename } from "node:path";
const BASE = "https://api.example.com";
const auth = { Authorization: `Bearer ${process.env.SURSA_API_KEY}` };
async function call(method, path, body, headers = {}) {
const isJson = body !== undefined && !(body instanceof Uint8Array);
const response = await fetch(BASE + path, {
method,
headers: { ...auth, ...(isJson ? { "Content-Type": "application/json" } : {}), ...headers },
body: isJson ? JSON.stringify(body) : body,
});
if (!response.ok) throw new Error(`${method} ${path}: HTTP ${response.status} ${await response.text()}`);
return response.json();
}
export async function uploadLargeFile(file, contentType, purpose, parallel = 4) {
const { size } = await stat(file);
const upload = await call("POST", "/v1/uploads",
{ filename: basename(file), content_type: contentType, size, purpose },
{ "Idempotency-Key": crypto.randomUUID() });
const hash = createHash("sha256");
for await (const block of createReadStream(file)) hash.update(block);
const handle = await open(file);
let next = 1;
async function worker() {
for (let n = next++; n <= upload.part_count; n = next++) {
const buffer = new Uint8Array(Math.min(upload.part_size, size - (n - 1) * upload.part_size));
await handle.read(buffer, 0, buffer.length, (n - 1) * upload.part_size);
for (let attempt = 1; ; attempt++) {
try {
await call("PUT", `/v1/uploads/${upload.id}/parts/${n}`, buffer, { "Content-Type": "application/octet-stream" });
break;
} catch (error) {
if (attempt === 5) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
}
}
}
}
await Promise.all(Array.from({ length: parallel }, worker));
await handle.close();
await call("POST", `/v1/uploads/${upload.id}/complete`, { sha256: hash.digest("hex") },
{ "Idempotency-Key": `complete-${upload.id}` });
for (;;) {
const state = await call("GET", `/v1/uploads/${upload.id}`);
if (state.status === "ready") return upload.id;
if (state.status === "rejected") throw new Error(`upload rejected: ${state.reject_reason}`);
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}Errors
| Code | Status | Cause |
|---|---|---|
uploads.too_large | 413 | The file is larger than 5 GB, or larger than the whole storage of the organization's plan. |
limits.storage | 402 | The organization has not enough free storage left for the file. |
uploads.part_invalid | 400 | A part number out of range, or a part outside the allowed size. |
uploads.checksum_mismatch | 422 | The SHA-256 of the assembled file is not the one you sent. A part was corrupted or is missing: send the parts again and complete again. |
uploads.not_ready | 409 | The upload is not completed and checked yet. Wait for ready. |
uploads.already_used | 409 | The upload has already been used. Each upload is used once. |
uploads.expired | 410 | More than 24 hours have passed. Create a new upload. |
uploads.rejected | 422 | The file did not pass its checks, for example its format does not match its name. The message says why. |
uploads.not_found | 404 | No such upload in the organization. |