Skip to content

Files

Reserving an upload, PUTting the bytes, confirming, and the one id that changes on the way.

A file belongs to a patient. The chart files a document against one, so a file reserved without a patient would have nowhere to land, and naming the patient at reservation is what opened live mode for this resource.

Files never cross the 1 MB request cap. Uploading one is three steps: reserve, PUT, confirm.

1. Reserve

POST /v1/files needs files:write and takes the patient, the kind, the filename, the MIME type and the exact byte length.

curl https://api.cuvo.co/v1/files \
  -H "Authorization: Bearer cuvo_sk_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: file-a1c-2026-09-1" \
  -d '{
    "patient": "pat_9f2b7c4a",
    "kind": "lab_result",
    "filename": "a1c-2026-09.pdf",
    "mime": "application/pdf",
    "size": 184320
  }'

kind is photo, id_document, lab_result or other. The chart has no photo type, so a photo filed in live mode reads back as other. mime is one of application/pdf, image/jpeg, image/png or image/heic: live storage signs for exactly one of these, so the sandbox publishes the same list rather than letting a type through that would fail the day it mattered. size is at most 25 MB.

The response is the only one that carries the upload URL. It is not readable again. size comes back as 0 here rather than as the size you declared: nothing has arrived yet, and this service reports the bytes it has counted, never the number you told it to expect.

{
  "id": "file_3c9a71d2",
  "patient": "pat_9f2b7c4a",
  "kind": "lab_result",
  "filename": "a1c-2026-09.pdf",
  "mime": "application/pdf",
  "size": 0,
  "status": "pending",
  "upload_url": "https://uploads.example.com/…",
  "upload_headers": { "content-type": "application/pdf" },
  "upload_expires_at": "2026-09-08T15:02:11Z",
  "created_at": "2026-09-08T14:02:11Z"
}

2. PUT the bytes

Send the bytes to upload_url with every header in upload_headers, and nothing else. The URL is signed for those headers, so a PUT without them is refused by storage rather than by this service. Do not send your API key: the signed URL is the credential.

curl -X PUT "$UPLOAD_URL" \
  -H "Content-Type: application/pdf" \
  --data-binary @a1c-2026-09.pdf

3. Confirm

POST /v1/files/{id}/confirm makes the file attachable. It repeats the patient, the kind and the filename, because storage addresses the object by all three and this service keeps no copy to look them up in. A filename that differs from the one the upload was signed for addresses nothing, so the sandbox refuses a mismatch exactly as live storage does.

curl https://api.cuvo.co/v1/files/file_3c9a71d2/confirm \
  -H "Authorization: Bearer cuvo_sk_test_…" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: file-a1c-2026-09-confirm" \
  -d '{ "patient": "pat_9f2b7c4a", "kind": "lab_result", "filename": "a1c-2026-09.pdf" }'

The id can change here. What the reservation answered with identifies the reservation; what the confirmation answers with identifies the filed document. In live mode those are two different things and their ids differ. In the sandbox they are one row and the id happens to be the same. Use the id from the response you last received and both modes behave identically. A reservation is not readable through GET /v1/files/{id} once it has been confirmed.

Reading and deleting

GET /v1/files/{id} needs files:read and returns the metadata, never the bytes.

DELETE /v1/files/{id} needs files:write and removes a file that is not attached to a case. It is test mode only. In live mode it answers 501 live_not_available, because the internal clinical contract has no delete and a 501 is the honest answer rather than a permission error.

Attaching to a case

POST /v1/cases takes up to twenty confirmed files in test mode. A live case is refused if that array is not empty: a live file is already filed on the patient's chart, and the clinical contract takes no file ids on a case. Reserve and confirm the file against the patient, and the clinician sees it on the chart.