Workflows
Attach files to a consultation
Upload lab results, referral letters and images to a consultation so Vetnio can read them when it writes the clinical note, then list, download and delete them.
Vets rarely write a note from the conversation alone. Blood results, a referral letter from another clinic or a photo of a wound all belong in the record. When you attach such a file to a consultation, Vetnio extracts its text in the background and uses it the next time it writes a document for that consultation, just like the Upload Files action in the Vetnio app.
Uploading takes three calls: reserve a slot, PUT the bytes to storage, and finalize. Every request below was run against a Vetnio instance with synthetic data.
Before you start
You need a personal API key (Quickstart) and a consultation to attach the file to (Document a visit). Any member of the clinic can attach, list, download and delete files. Archived consultations are read-only: you can still list and download their files, but uploads and deletions return 403.
Vetnio accepts these formats and reads them as follows:
| File | mimeType | How the text is extracted |
|---|---|---|
application/pdf | OCR, including scanned pages | |
| PNG, JPEG, WebP, GIF | image/png, image/jpeg, image/webp, image/gif | An AI model describes the image |
| Plain text | text/plain | Read as UTF-8 |
| CSV | text/csv | Read as UTF-8 |
| Word | application/vnd.openxmlformats-officedocument.wordprocessingml.document | Converted to text |
| Excel | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, application/vnd.ms-excel | First 10,000 rows and 50 columns of each sheet |
Any other mimeType is rejected with 400. Vetnio does not compare the type with the file's content, so send the real type: it decides how the text is extracted. There is no size limit in the API.
1. Reserve a slot
Compute the SHA-256 digest and the exact byte size of the file:
shasum -a 256 bella-bloodwork-2026-10-02.txt
wc -c < bella-bloodwork-2026-10-02.txtThen call POST/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files:
curl -X POST https://vetnio.com/api/v2/workspaces/demo-north-clinic/consultations/4b2730b1-3e78-4d82-9114-030a055e8581/uploaded-files \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"originalFileName": "bella-bloodwork-2026-10-02.txt",
"sha256": "0cd9f11e22e7cde3a23cedb9eace270a4bcb0b3f135c17567f027b38578e3621",
"mimeType": "text/plain",
"sizeBytes": 248
}'{
"uploadUrl": "https://storage.example.com/consultationInformation/demo-north-clinic/uploadedFiles/4b2730b1-3e78-4d82-9114-030a055e8581/58aecfdf-c3eb-4fa6-ad16-09d504f18363?X-Amz-Expires=3600&X-Amz-Signature=…",
"s3Key": "consultationInformation/demo-north-clinic/uploadedFiles/4b2730b1-3e78-4d82-9114-030a055e8581/58aecfdf-c3eb-4fa6-ad16-09d504f18363",
"fileName": "58aecfdf-c3eb-4fa6-ad16-09d504f18363"
}fileName is the file's ID in every later call; originalFileName is only for display. The slot exists from now on and is listed with status uploaded until you finalize it. s3Key is informational; do not build on its format.
The digest is used only to stop the same file being attached twice to one consultation. A second slot with the same sha256 returns 409, even when the first upload was never finished:
{
"message": "File has already been uploaded",
"code": "CONFLICT",
"data": {
"code": "CONFLICT",
"httpStatus": 409,
"path": "uploadedFiles.initUpload",
"zodError": null
}
}2. Upload the bytes
PUT the raw file to uploadUrl within one hour. Do not send an Authorization header or a multipart form; the URL carries its own signature.
curl -X PUT --upload-file bella-bloodwork-2026-10-02.txt \
-H "Content-Type: text/plain" \
"https://storage.example.com/consultationInformation/demo-north-clinic/uploadedFiles/4b2730b1-3e78-4d82-9114-030a055e8581/58aecfdf-c3eb-4fa6-ad16-09d504f18363?X-Amz-Expires=3600&X-Amz-Signature=…"Storage answers 200 with an empty body. If the URL expired first, delete the slot (step 6) and start again; there is no way to renew the URL.
3. Finalize and start the analysis
POST/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files/{fileName}/finalize checks that storage holds exactly sizeBytes bytes and starts the text extraction. Send {} as the body.
curl -X POST https://vetnio.com/api/v2/workspaces/demo-north-clinic/consultations/4b2730b1-3e78-4d82-9114-030a055e8581/uploaded-files/58aecfdf-c3eb-4fa6-ad16-09d504f18363/finalize \
-H "Authorization: Bearer $VETNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'{
"ok": true
}Called before the upload has completed, finalize returns 412:
{
"message": "The file upload is not complete. Wait for the upload to finish and try again.",
"code": "PRECONDITION_FAILED",
"data": {
"code": "PRECONDITION_FAILED",
"httpStatus": 412,
"path": "uploadedFiles.finalizeUpload",
"zodError": null
}
}The same 412 appears when the uploaded file is larger or smaller than the sizeBytes you declared, and then retrying never helps. If 412 persists after a successful PUT, delete the slot and reserve a new one with the correct size.
Finalize is safe to repeat. On a file that is analyzing or analyzed it does nothing and returns { "ok": true }; on a failed file it runs the extraction again.
4. Wait for the analysis
Poll GET/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files every 2 seconds until the file's status is analyzed or failed. Text, CSV, Word and Excel files are converted within seconds; PDFs and images take longer because they go through OCR or an AI model.
curl https://vetnio.com/api/v2/workspaces/demo-north-clinic/consultations/4b2730b1-3e78-4d82-9114-030a055e8581/uploaded-files \
-H "Authorization: Bearer $VETNIO_API_KEY"[
{
"fileName": "58aecfdf-c3eb-4fa6-ad16-09d504f18363",
"originalFileName": "bella-bloodwork-2026-10-02.txt",
"mimeType": "text/plain",
"sizeBytes": 248,
"status": "analyzed",
"createdAt": "2026-10-04T16:18:23.875Z",
"errorMessage": null
}
]The list holds every file on the consultation, newest first, with no pagination.
status | Meaning | What to do |
|---|---|---|
uploaded | Slot reserved; the bytes may not be there yet | Upload, then finalize. |
analyzing | Text extraction is running | Keep polling. |
analyzed | The text is ready and will be used by generation | Done. |
failed | Extraction failed; errorMessage holds a technical description | Call finalize again to retry. |
errorMessage is meant for your logs, not for clinic staff. If a file is still analyzing long after the request time limit in Limits has passed, its analysis was cut off and finalize will not restart it: delete the file and upload it again.
How files feed the clinical note
Once a file is analyzed, its extracted text becomes part of the input for the documents Vetnio writes for the consultation: clinical notes from POST/workspaces/{workspaceId}/consultations/{consultationId}/clinical-note/generate, records generated with POST/workspaces/{workspaceId}/consultations/{consultationId}/records, and structured notes. Generation reads the 10 most recently added analyzed files and the first 20,000 characters of each.
Some details that matter when you plan the order of calls:
- Attach files before you generate. A note that already exists is not updated when a file arrives; generate again to include it.
- An analyzed file makes GET
/workspaces/{workspaceId}/consultations/{consultationId}reportisReadyForGeneration: true, even without audio. - POST
/workspaces/{workspaceId}/consultations/{consultationId}/clinical-note/generatestill needs a non-empty transcript and returns400without one. To write a record from files alone, use POST/workspaces/{workspaceId}/consultations/{consultationId}/records. - The extracted text cannot be read through the API. Download the original instead.
5. Download a file
GET/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files/{fileName}/download-url returns a pre-signed link to the original bytes, valid for one hour, with the name and type to serve it under:
curl https://vetnio.com/api/v2/workspaces/demo-north-clinic/consultations/4b2730b1-3e78-4d82-9114-030a055e8581/uploaded-files/58aecfdf-c3eb-4fa6-ad16-09d504f18363/download-url \
-H "Authorization: Bearer $VETNIO_API_KEY"{
"url": "https://storage.example.com/consultationInformation/demo-north-clinic/uploadedFiles/4b2730b1-3e78-4d82-9114-030a055e8581/58aecfdf-c3eb-4fa6-ad16-09d504f18363?X-Amz-Expires=3600&X-Amz-Signature=…",
"originalFileName": "bella-bloodwork-2026-10-02.txt",
"mimeType": "text/plain"
}Fetch a new link whenever you need one instead of storing it.
6. Delete a file
DELETE/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files/{fileName} removes the original and its extracted text from storage, then the file itself:
curl -X DELETE https://vetnio.com/api/v2/workspaces/demo-north-clinic/consultations/4b2730b1-3e78-4d82-9114-030a055e8581/uploaded-files/58aecfdf-c3eb-4fa6-ad16-09d504f18363 \
-H "Authorization: Bearer $VETNIO_API_KEY"{
"ok": true
}Deleting cannot be undone through the API, and it is idempotent: deleting a file that is already gone also returns { "ok": true }. If storage cannot remove a part, the call returns 500 and keeps the file so that you can retry. Notes that were generated while the file was attached are not changed. Deleting is also how you clear an abandoned slot so the same file can be uploaded again.
Common errors
| Status | Message | Cause | Fix |
|---|---|---|---|
400 | Input validation failed | Unsupported mimeType, a malformed sha256 or a non-positive sizeBytes | Read issues[]; send one of the listed types. |
403 | You cannot modify this consultation | The consultation is archived | Attach the file to an active consultation. |
403 | Storage error (XML) on PUT | The upload URL expired or was altered | Delete the slot and reserve a new one. |
404 | File not found | Unknown fileName on finalize or download | Use the fileName returned when you reserved the slot. |
409 | File has already been uploaded | A file with the same digest is already on this consultation | Nothing to do, or delete the unfinished slot and retry. |
412 | The file upload is not complete… | The upload has not finished, or its size differs from sizeBytes | Wait and retry; if it persists, delete and reserve again. |
415 | Missing content-type header | Finalize sent without a JSON body | Send Content-Type: application/json and {}. |
See Errors for the error format.
End-to-end script
The script attaches one file and waits for its analysis. It needs Node.js 18 or later and nothing else.
// attach-file.mjs: Node.js 18 or later, no dependencies.
// VETNIO_API_KEY=sk_live_… node attach-file.mjs <workspaceId> <consultationId> <file>
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { basename, extname } from "node:path";
const BASE_URL = process.env.VETNIO_BASE_URL ?? "https://vetnio.com/api/v2";
const API_KEY = process.env.VETNIO_API_KEY;
const [workspaceId, consultationId, filePath] = process.argv.slice(2);
const MIME_TYPES = {
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".txt": "text/plain",
".csv": "text/csv",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".xls": "application/vnd.ms-excel",
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
class ApiError extends Error {
constructor(status, message) {
super(message);
this.status = status;
}
}
async function api(method, path, body) {
const hasBody = method !== "GET" && method !== "DELETE";
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers: {
Authorization: `Bearer ${API_KEY}`,
...(hasBody ? { "Content-Type": "application/json" } : {}),
},
body: hasBody ? JSON.stringify(body ?? {}) : undefined,
});
const payload = await response.json();
if (!response.ok) throw new ApiError(response.status, `${method} ${path}: ${payload.message}`);
return payload;
}
const files = `/workspaces/${workspaceId}/consultations/${consultationId}/uploaded-files`;
const mimeType = MIME_TYPES[extname(filePath).toLowerCase()];
if (!mimeType) throw new Error(`Vetnio cannot analyze ${filePath}`);
const bytes = await readFile(filePath);
// Step 1: reserve a slot. The digest and size must describe exactly these bytes.
let slot;
try {
slot = await api("POST", files, {
originalFileName: basename(filePath),
sha256: createHash("sha256").update(bytes).digest("hex"),
mimeType,
sizeBytes: bytes.length,
});
} catch (error) {
if (error.status !== 409) throw error;
console.log(`${basename(filePath)} is already attached. If its upload was never finished, delete that file first.`);
process.exit(0);
}
// Step 2: upload the bytes to storage within the hour.
const upload = await fetch(slot.uploadUrl, {
method: "PUT",
headers: { "Content-Type": mimeType },
body: bytes,
});
if (!upload.ok) throw new Error(`Upload failed with ${upload.status}`);
// Step 3: finalize. 412 means storage does not hold all the bytes yet.
for (let attempt = 1; ; attempt += 1) {
try {
await api("POST", `${files}/${slot.fileName}/finalize`, {});
break;
} catch (error) {
if (error.status !== 412 || attempt === 5) throw error;
await sleep(attempt * 1_000);
}
}
// Step 4: poll until the analysis ends; retry a failed analysis once.
let retried = false;
const deadline = Date.now() + 15 * 60_000;
while (Date.now() < deadline) {
await sleep(2_000);
const file = (await api("GET", files)).find((candidate) => candidate.fileName === slot.fileName);
if (file.status === "analyzed") {
console.log(`${file.originalFileName} is analyzed (${slot.fileName})`);
process.exit(0);
}
if (file.status === "failed") {
if (retried) throw new Error(`Analysis failed: ${file.errorMessage}`);
retried = true;
await api("POST", `${files}/${slot.fileName}/finalize`, {});
}
}
throw new Error(`Analysis of ${slot.fileName} did not finish; delete the file and upload it again`);Next steps
- Document a visit end to end: record, transcribe and generate the note that uses these files.
- Consultations: readiness and the other status fields.
- Records: the documents Vetnio writes and how to read them.
Endpoints used in this guide
- POST
/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-filesInitialize a file upload - POST
/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files/{fileName}/finalizeFinalize a file upload and trigger analysis - GET
/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-filesList uploaded files for a consultation - GET
/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files/{fileName}/download-urlGet a download URL for an uploaded file - DELETE
/workspaces/{workspaceId}/consultations/{consultationId}/uploaded-files/{fileName}Delete an uploaded file - GET
/workspaces/{workspaceId}/consultations/{consultationId}Get consultation details - POST
/workspaces/{workspaceId}/consultations/{consultationId}/clinical-note/generateGenerate a clinical note - POST
/workspaces/{workspaceId}/consultations/{consultationId}/recordsCreate a record
Last reviewed against the API on 2026-10-13.