Skip to content
Sursa Developers

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:

  1. Create an upload: tell Sursa the name, type and size of the file.
  2. Send the parts, in any order and in parallel if you like. A failed part is sent again on its own.
  3. Complete the upload with the SHA-256 of the whole file. Sursa assembles the file and checks it.
  4. Use the upload ID in place of the file: for a card, an archive, or a document of a custom source.
LimitValue
File sizeUp to 5 GB
Part sizeChosen by Sursa in part_size, from 5 MiB to 100 MiB; the last part can be smaller
Number of partsUp to 1000
Time to complete24 hours after the upload is created
Time to use a completed upload24 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.sursa.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"
}
purposeThe upload will be used for
card_fileA card: POST /v1/cards.
archiveAn archive of documents: POST /v1/cards/archive.
import_attachmentA 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.sursa.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))
done

Sending 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.sursa.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.sursa.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.sursa.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.sursa.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

CodeStatusCause
uploads.too_large413The file is larger than 5 GB, or larger than the whole storage of the organization's plan.
limits.storage402The organization has not enough free storage left for the file.
uploads.part_invalid400A part number out of range, or a part outside the allowed size.
uploads.checksum_mismatch422The 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_ready409The upload is not completed and checked yet. Wait for ready.
uploads.already_used409The upload has already been used. Each upload is used once.
uploads.expired410More than 24 hours have passed. Create a new upload.
uploads.rejected422The file did not pass its checks, for example its format does not match its name. The message says why.
uploads.not_found404No such upload in the organization.