Enviar arquivos grandes
Um arquivo pode ir em uma única requisição de POST /v1/cards até o limite de upload
da instalação (200 MB por padrão). Acima disso, ou em uma rede em que uma requisição longa provavelmente vai cair,
envie o arquivo em partes:
- Crie um upload: informe ao Sursa o nome, o tipo e o tamanho do arquivo.
- Envie as partes, em qualquer ordem e em paralelo, se quiser. Uma parte que falhou é enviada de novo sozinha.
- Conclua o upload com o SHA-256 do arquivo inteiro. O Sursa monta o arquivo e o confere.
- Use o ID do upload no lugar do arquivo: para um cartão, um arquivo compactado ou um documento de uma fonte personalizada.
| Limite | Valor |
|---|---|
| Tamanho do arquivo | Até 5 GB |
| Tamanho da parte | Escolhido pelo Sursa em part_size, de 5 MiB a 100 MiB; a última parte pode ser menor |
| Número de partes | Até 1000 |
| Prazo para concluir | 24 horas depois da criação do upload |
| Prazo para usar um upload concluído | 24 horas |
1. Criar o upload
POST /v1/uploads recebe o nome, o tipo e o tamanho do arquivo e a finalidade
para a qual você vai usá-lo:
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"
}
purpose | O upload será usado para |
|---|---|
card_file | Um cartão: POST /v1/cards. |
archive | Um arquivo compactado de documentos: POST /v1/cards/archive. |
import_attachment | Um arquivo de um documento de uma fonte personalizada, em upload_id ou attachments. |
A chave precisa do escopo do destino do upload: cards:write para card_file e archive, import:write para
import_attachment.
O Sursa escolhe part_size e informa quantas partes enviar. Se você já souber o SHA-256 do arquivo, também pode
passá-lo aqui como sha256.
2. Enviar as partes
Divida o arquivo em part_count pedaços de part_size bytes (o último fica com o restante) e envie cada um com
PUT /v1/uploads/{upload_id}/parts/{n}. As partes são numeradas a partir de 1. O
corpo são os bytes brutos da parte.
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))
doneEnviar uma parte de novo a substitui, então uma parte que falhou ou excedeu o tempo limite simplesmente é enviada de novo. Até 4 partes em paralelo costumam saturar uma conexão rápida.
Conferir as partes
Todas as partes passam pela API: não há links diretos para o armazenamento. A resposta a uma parte traz o cabeçalho
ETag, o SHA-256 da parte entre aspas, e o mesmo valor em etag do corpo JSON.
GET /v1/uploads/{upload_id} lista em parts as partes recebidas até o momento,
então, depois de uma interrupção, você envia só as que faltam.
3. Concluir o upload
POST /v1/uploads/{upload_id}/complete recebe o SHA-256 do arquivo inteiro
como 64 caracteres hexadecimais e, se você quiser, o etag de cada parte em parts, para conferir também as partes:
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\"}"O Sursa monta as partes, compara o hash e confere o arquivo como confere qualquer upload: o formato e, onde estiver
ativada, a varredura antivírus. A resposta é 200 quando a conferência termina e 202 enquanto ela ainda está em
andamento. O status do upload passa de processing para ready, ou para rejected com o motivo em reject_reason.
Leia-o com GET /v1/uploads/{upload_id}.
4. Usar o upload
Passe upload_id onde você enviaria o arquivo. Cada upload é usado uma única vez: depois disso, o status dele é
consumed.
Para um cartão e um arquivo compactado, envie upload_id em um corpo JSON em vez do formulário multipart/form-data.
Esse corpo recebe a coleção pelo nome, em 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\"}"Um programa de upload completo
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));
}
}Erros
| Código | Status | Causa |
|---|---|---|
uploads.too_large | 413 | O arquivo tem mais de 5 GB ou é maior que todo o armazenamento do plano da organização. |
limits.storage | 402 | A organização não tem armazenamento livre suficiente para o arquivo. |
uploads.part_invalid | 400 | Um número de parte fora do intervalo ou uma parte fora do tamanho permitido. |
uploads.checksum_mismatch | 422 | O SHA-256 do arquivo montado não é o que você enviou. Uma parte foi corrompida ou está faltando: envie as partes de novo e conclua de novo. |
uploads.not_ready | 409 | O upload ainda não foi concluído e conferido. Aguarde ready. |
uploads.already_used | 409 | O upload já foi usado. Cada upload é usado uma única vez. |
uploads.expired | 410 | Passaram-se mais de 24 horas. Crie um novo upload. |
uploads.rejected | 422 | O arquivo não passou nas conferências, por exemplo porque o formato não corresponde ao nome. A mensagem diz o motivo. |
uploads.not_found | 404 | Não existe esse upload na organização. |