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 |
Two body modes
Section titled “Two body modes”Pick whichever your HTTP client makes easy.
# 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# What curl -F, Postman and most SDKs send.curl -X POST "https://api.vendorica.com/v1/contracts/$CONTRACT/documents" \ -H "Authorization: Bearer $VENDORICA_API_KEY" \ -F "file=@msa.pdf" \ -F "docType=agreement"Raw bodies must send Content-Length — 411 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.
Metadata
Section titled “Metadata”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.
Integrity
Section titled “Integrity”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.
Idempotency, and resuming a migration
Section titled “Idempotency, and resuming a migration”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
supersedesDocumentIddisables 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.
File types
Section titled “File types”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 upload is not the end
Section titled “The upload is not the end”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" } }}Failures worth handling by name
Section titled “Failures worth handling by name”| 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. |
Reading documents back
Section titled “Reading documents back”GET /v1/evidence/{id}/download and
GET /v1/contracts/documents/{docId}/download stream the stored bytes with
Content-Disposition: attachment — not the JSON envelope.
Evidence flagged sensitive is refused to an API key with 403, and the
attempt is audited. See authentication.