Direct naar de inhoud
Sursa Developers

Grote bestanden uploaden

Een bestand kan in één request van POST /v1/cards worden verstuurd tot de uploadlimiet van de installatie (standaard 200 MB). Is het groter, of zit je op een netwerk waar een lang request waarschijnlijk afbreekt, upload het bestand dan in delen:

  1. Maak een upload: geef Sursa de naam, het type en de grootte van het bestand.
  2. Stuur de delen, in willekeurige volgorde en desgewenst parallel. Een mislukt deel wordt los opnieuw verstuurd.
  3. Rond de upload af met de SHA-256 van het hele bestand. Sursa voegt het bestand samen en controleert het.
  4. Gebruik de ID van de upload in plaats van het bestand: voor een kaart, een archief of een document van een eigen bron.
LimietWaarde
BestandsgrootteMaximaal 5 GB
Grootte van een deelGekozen door Sursa in part_size, van 5 MiB tot 100 MiB; het laatste deel mag kleiner zijn
Aantal delenMaximaal 1000
Tijd om af te ronden24 uur nadat de upload is gemaakt
Tijd om een afgeronde upload te gebruiken24 uur

1. Upload maken

POST /v1/uploads neemt de naam, het type en de grootte van het bestand aan, en waarvoor je het gaat gebruiken:

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"
}
purposeDe upload wordt gebruikt voor
card_fileEen kaart: POST /v1/cards.
archiveEen archief met documenten: POST /v1/cards/archive.
import_attachmentEen bestand van een document van een eigen bron, in upload_id of attachments.

De sleutel heeft de scope nodig van de plek waar de upload naartoe gaat: cards:write voor card_file en archive, import:write voor import_attachment.

Sursa kiest part_size en vertelt je hoeveel delen je moet versturen. Weet je de SHA-256 van het bestand al van tevoren, dan mag je die hier ook meegeven als sha256.

2. Delen versturen

Knip het bestand in part_count stukken van part_size bytes (het laatste stuk is de rest) en stuur elk stuk met PUT /v1/uploads/{upload_id}/parts/{n}. Delen worden genummerd vanaf 1. De body bestaat uit de ruwe bytes van het deel.

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

Een deel opnieuw versturen vervangt het, dus een deel dat is mislukt of een time-out kreeg, stuur je gewoon opnieuw. Maximaal 4 delen parallel benutten een snelle verbinding meestal volledig.

Delen controleren

Elk deel gaat via de API: er zijn geen directe links naar de opslag. De response op een deel heeft de header ETag, de SHA-256 van het deel tussen aanhalingstekens, en dezelfde waarde in etag van de JSON-body. GET /v1/uploads/{upload_id} somt in parts de delen op die tot nu toe zijn ontvangen, dus na een onderbreking stuur je alleen de ontbrekende delen.

3. Upload afronden

POST /v1/uploads/{upload_id}/complete neemt de SHA-256 van het hele bestand aan als 64 hexadecimale tekens en, optioneel, de etag van elk deel in parts, om ook de delen te controleren:

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 voegt de delen samen, vergelijkt de hash en controleert het bestand zoals elke upload: het formaat en, waar die is ingeschakeld, de virusscan. De response is 200 als de controle klaar is en 202 zolang die nog loopt. De status van de upload gaat van processing naar ready, of naar rejected met de reden in reject_reason. Lees hem met GET /v1/uploads/{upload_id}.

4. Upload gebruiken

Geef upload_id mee waar je anders het bestand zou sturen. Elke upload wordt één keer gebruikt: daarna is de status ervan consumed.

Stuur voor een kaart en een archief upload_id in een JSON-body in plaats van het formulier multipart/form-data. Deze body neemt de collectie aan op haar naam 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\"}"

Een volledige 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));
  }
}

Fouten

CodeStatusOorzaak
uploads.too_large413Het bestand is groter dan 5 GB, of groter dan de hele opslag van het abonnement van de organisatie.
limits.storage402De organisatie heeft niet genoeg vrije opslag meer voor het bestand.
uploads.part_invalid400Een deelnummer buiten het bereik, of een deel buiten de toegestane grootte.
uploads.checksum_mismatch422De SHA-256 van het samengevoegde bestand is niet de waarde die je hebt gestuurd. Een deel is beschadigd of ontbreekt: stuur de delen opnieuw en rond opnieuw af.
uploads.not_ready409De upload is nog niet afgerond en gecontroleerd. Wacht op ready.
uploads.already_used409De upload is al gebruikt. Elke upload wordt één keer gebruikt.
uploads.expired410Er zijn meer dan 24 uur verstreken. Maak een nieuwe upload.
uploads.rejected422Het bestand heeft de controles niet doorstaan, bijvoorbeeld omdat het formaat niet bij de naam past. De foutmelding zegt waarom.
uploads.not_found404Zo’n upload bestaat niet in de organisatie.