Skip to content

Uploading documents

Two endpoints take a file and the record that owns it in one request:

Endpoint Scope Files
POST /v1/controls/{controlId}/evidence evidence:write evidence for a control
POST /v1/contracts/{id}/documents contracts:write an agreement, DPA, SLA, order form, amendment or exit plan

Pick whichever your HTTP client makes easy.

Terminal window
# The mode a purpose-built migration client wants: no multipart framing
# to generate, and Content-Length is exact.
curl -X POST "https://api.vendorica.com/v1/controls/$CONTROL/evidence?filename=soc2.pdf&summary=SOC%202%20Type%20II" \
-H "Authorization: Bearer $VENDORICA_API_KEY" \
-H "Content-Type: application/octet-stream" \
-H "X-Checksum-Sha256: $(sha256sum soc2.pdf | cut -d' ' -f1)" \
--data-binary @soc2.pdf

Raw bodies must send Content-Length411 otherwise. The size ceiling and your organization’s storage quota are both answered before the bytes move, and a chunked body can only be measured by consuming it.

In multipart, text fields must come before the file part. The parser is streaming, so a field after the file arrives too late to apply — and one that does is refused with 400 rather than silently dropped. The refusal is on the field’s position, not on timing, so the answer never depends on how your body happened to be segmented on the wire.

Raw mode reads metadata from the query string; multipart reads it from sibling text fields. The names are the same either way.

Endpoint Fields
Evidence filename, summary, description, effectiveDate, isSensitive
Contract documents filename, docType, supersedesDocumentId

docType is one of agreement, dpa, sla, order_form, amendment, exit_plan, other. A replacement inherits the type of what it replaces unless you state one.

Send X-Checksum-Sha256: <hex> and the digest is verified against what the vault actually received. On a mismatch nothing is stored — no file, no record — and the response is 422 with details.reason of checksum_mismatch. A malformed digest is a 400 before the transfer even starts.

This is what makes an unattended migration verifiable rather than hopeful: without it, a truncated transfer becomes a document that looks intact.

Both endpoints are idempotent on file content. If the target already carries current bytes with the same hash, you get the existing record back with 200 and Idempotent-Replay: true instead of a duplicate.

That matters more than it sounds. A duplicate would not merely duplicate — it would supersede the record it copies, churning a control’s status or a contract’s document history for no change in fact.

Send X-Checksum-Sha256 and the replay is answered before the body is transferred. Re-running an interrupted batch then costs one lookup per already-done document instead of re-uploading it. Two limits on that:

  • On a replay the stored record comes back unchanged — metadata sent alongside identical bytes is not applied. Identical content means nothing happened.
  • On contract documents, naming supersedesDocumentId disables the replay: that is an instruction about the register, not a retry. In multipart the field arrives behind the file, so the short-circuit is skipped for multipart uploads entirely — you keep the correctness, you lose only the bandwidth saving.

250 MB per request. The ceiling is checked against Content-Length before the body is read and enforced mid-stream, so an under-declared length buys nothing. Over it: 413 PAYLOAD_TOO_LARGE, with details.maxBytes so a client can size its own batching without parsing the message.

Uploads stream — nothing buffers the whole file — so the practical limit on throughput is the rate budget, not the file size.

Checked twice: the extension must be on the surface’s allow-list, and a magic-byte sniff of the file’s own header must agree with it. The Content-Type you declare is never trusted and never stored; what is persisted is derived from the sniff.

Accepted for both surfaces: pdf, docx, xlsx, csv, txt, png, jpg, jpeg, zip — plus pptx and webp for evidence.

Refusals are 422 with a machine-readable details.reason: file_type_not_allowed, file_content_mismatch, or file_type_unverifiable.

The response carries file.scanStatus. A document is not necessarily downloadable the moment the call returns — poll GET /v1/evidence/{id}, or list the contract’s documents, to follow it.

{
"success": true,
"data": {
"evidence": { "id": "1f0c...", "controlId": "9ab2...", "isSensitive": false },
"file": {
"id": "77de...",
"filename": "soc2.pdf",
"mimeType": "application/pdf",
"sizeBytes": 1048576,
"contentHash": "9f86d081884c7d65...",
"scanStatus": "pending"
}
}
}
Status Code What to do
402 STORAGE_QUOTA_EXCEEDED Stop the run — every later upload fails too.
404 NOT_FOUND The control or contract is not in this organization.
411 LENGTH_REQUIRED Send Content-Length on a raw body.
413 PAYLOAD_TOO_LARGE Split or skip; details.maxBytes is the ceiling.
422 VALIDATION_ERROR Read details.reason — file type or checksum.
503 SERVICE_UNAVAILABLE Document storage is unavailable. Retry with backoff.

GET /v1/evidence/{id}/download and GET /v1/contracts/documents/{docId}/download stream the stored bytes with Content-Disposition: attachmentnot the JSON envelope.

Evidence flagged sensitive is refused to an API key with 403, and the attempt is audited. See authentication.