Workflows
Export clinical data
Copy a clinic's consultations, transcripts, clinical notes and audio into your own archive or data warehouse, with time windows, pagination and an incremental sync.
Clinics keep their own copy of what Vetnio records: for a long-term archive, a data warehouse, quality reviews or a move to another system. This guide shows how a clinic admin pulls every consultation of a clinic, with its transcript, clinical note, other records and audio, and how to keep that copy up to date with a nightly incremental sync.
Every request below was run against a Vetnio instance with synthetic data.
Before you start
The export endpoints are for clinic administrators:
- Use a personal API key (Quickstart) of a user who is an admin or superadmin of the clinic, or an admin of the organization that contains it. Other members get
403 You are not an admin of this workspace. - Organization and clinic keys (
sk_org_…,sk_ws_…) cannot call these endpoints and get403 This API key cannot access this endpoint. Those keys are for the organization overview and the usage export. - The export covers one clinic per call. GET
/workspaceslists the clinics your key can reach and your role in each.
The export includes every consultation in the clinic, whoever created it, and the audio even when a recording is private to the vet who made it. Treat the output as patient records.
What you can export
Each consultation offers up to six files, each behind its own pre-signed download URL:
| Field | Content | Format |
|---|---|---|
audioRecording | All uploaded audio, joined into one file, silence included | WAV, 16 kHz mono |
transcription | The full transcript, as returned by Document a visit | Plain text, UTF-8 |
clinicalNote | The current clinical note | HTML, or JSON for a structured note |
structuredDocument | The latest document generated from a structured template | JSON |
communication | The current client communication | HTML |
referral | The current referral letter | HTML |
A field is null when the file does not exist: no note has been written yet, the consultation has no audio, or the clinic's audio-retention policy has already removed the audio. Transcripts and notes are kept when audio is removed, so export audio before your clinic's retention period ends if you need it.
The export holds the current version of each record. Earlier versions are available per consultation from the record version endpoints in Records. Uploaded files and the linked patient are not part of the export.
1. List consultations in a time window
GET/workspaces/{workspaceId}/data-export/consultations returns a page of consultations with their download URLs. Filter by creation time with createdAtFrom (inclusive) and createdAtBefore (exclusive):
curl "https://vetnio.com/api/v2/workspaces/demo-north-clinic/data-export/consultations?page=1&pageSize=1&createdAtFrom=2026-09-01T00:00:00Z&createdAtBefore=2026-10-01T00:00:00Z" \
-H "Authorization: Bearer $VETNIO_API_KEY"{
"consultations": [
{
"structuredDocument": null,
"communication": "https://storage.example.com/records/demo-north-clinic/communication/ac4b9536-5ca9-5cf8-a612-d4660b314b22?X-Amz-Expires=3600&X-Amz-Signature=…",
"referral": "https://storage.example.com/records/demo-north-clinic/referral/ac4b9536-5ca9-5cf8-a612-d4660b314b22?X-Amz-Expires=3600&X-Amz-Signature=…",
"id": "ac4b9536-5ca9-5cf8-a612-d4660b314b22",
"name": "DEMO Pip - routine weight review",
"creatorId": "2aed2b6f-c9ca-559f-a22c-4c76861e4e07",
"createdAt": "2026-09-08T11:00:00.000Z",
"externalId": null,
"clinicalNoteTemplateId": "7ce0fa1b-777b-55f9-a58b-7fcabc410504",
"audioRecording": "https://storage.example.com/fullRecordings/demo-north-clinic/ac4b9536-5ca9-5cf8-a612-d4660b314b22?X-Amz-Expires=3600&X-Amz-Signature=…",
"transcription": "https://storage.example.com/transcriptions/demo-north-clinic/ac4b9536-5ca9-5cf8-a612-d4660b314b22?X-Amz-Expires=3600&X-Amz-Signature=…",
"clinicalNote": "https://storage.example.com/records/demo-north-clinic/clinical-note/ac4b9536-5ca9-5cf8-a612-d4660b314b22?X-Amz-Expires=3600&X-Amz-Signature=…"
}
],
"count": 3,
"page": 1,
"pageSize": 1,
"totalPages": 3,
"expiresInSeconds": 3600,
"expiresAt": "2026-10-04T17:33:21.410Z"
}How the list behaves:
| Parameter or field | Behavior |
|---|---|
page, pageSize | page starts at 1. pageSize is 1 to 50, default 10. A page past the end returns an empty consultations array. |
createdAtFrom, createdAtBefore | ISO 8601 with Z or an offset; a bare date is rejected. Encode + as %2B in a URL. createdAtBefore must be later than createdAtFrom. Omit either for an open range. |
searchQuery | Optional substring filter on name, note text, externalId and patient name or identifier. |
| Order | createdAt newest first, then id. |
count, totalPages | Totals for the filters, across all pages. |
expiresAt | When every URL in this response stops working, one hour after the request. |
creatorId is the user who created the consultation; map it to a person with the members of GET/organizations/{organizationId}. externalId is the visit ID in the clinic's practice system, if one was set.
The filters apply to creation time only. A note edited today on a consultation created last month does not appear in today's window; see Keep the copy up to date.
2. Download the files
The URLs point straight at storage. Fetch them with a plain GET and no Authorization header:
curl -o clinical-note.html \
"https://storage.example.com/records/demo-north-clinic/clinical-note/ac4b9536-5ca9-5cf8-a612-d4660b314b22?X-Amz-Expires=3600&X-Amz-Signature=…"The response's Content-Type tells you the format: text/html or application/json for the clinical note, text/plain for the transcript and audio/wav for the audio. Download the files of a page before you request the next one. If a download fails with 403 because the hour has passed, request the same page again for fresh URLs.
The downloaded clinical note is the note as stored. When its template asks Vetnio to highlight text for the vet to confirm, it can contain <span data-open-item="…" data-reason="…"> around text the vet has not confirmed yet. GET/workspaces/{workspaceId}/consultations/{consultationId}/records returns the same note with that text left out; see Records.
3. Export a single consultation
To refresh one consultation, for example after a clinician tells you a note changed, call GET/workspaces/{workspaceId}/consultations/{consultationId}/data-export:
curl https://vetnio.com/api/v2/workspaces/demo-north-clinic/consultations/dfd6e768-51dd-5fcb-a786-c6dffe511d5f/data-export \
-H "Authorization: Bearer $VETNIO_API_KEY"{
"structuredDocument": null,
"communication": "https://storage.example.com/records/demo-north-clinic/communication/dfd6e768-51dd-5fcb-a786-c6dffe511d5f?X-Amz-Expires=3600&X-Amz-Signature=…",
"referral": "https://storage.example.com/records/demo-north-clinic/referral/dfd6e768-51dd-5fcb-a786-c6dffe511d5f?X-Amz-Expires=3600&X-Amz-Signature=…",
"consultationId": "dfd6e768-51dd-5fcb-a786-c6dffe511d5f",
"workspaceId": "demo-north-clinic",
"externalId": null,
"expiresInSeconds": 3600,
"expiresAt": "2026-10-04T17:18:08.974Z",
"audioRecording": "https://storage.example.com/fullRecordings/demo-north-clinic/dfd6e768-51dd-5fcb-a786-c6dffe511d5f?X-Amz-Expires=3600&X-Amz-Signature=…",
"transcription": "https://storage.example.com/transcriptions/demo-north-clinic/dfd6e768-51dd-5fcb-a786-c6dffe511d5f?X-Amz-Expires=3600&X-Amz-Signature=…",
"clinicalNote": "https://storage.example.com/records/demo-north-clinic/clinical-note/dfd6e768-51dd-5fcb-a786-c6dffe511d5f?X-Amz-Expires=3600&X-Amz-Signature=…"
}The files are the same as in the list. Nothing is generated or changed by either call.
Archived and deleted consultations
The list includes archived consultations and consultations deleted in the Vetnio app, and nothing in a row says which they are. The single export refuses them, with a misleading message:
{
"message": "You are not an admin of this consultation workspace",
"code": "FORBIDDEN",
"data": {
"code": "FORBIDDEN",
"httpStatus": 403,
"path": "consultations.getDataExport",
"zodError": null,
"reason": "archived_consultation"
}
}So export from the list, which works for every row. If you need to know a row's state, call the single export for it: reason is archived_consultation or hidden_consultation (deleted in the app). Archived consultations can be restored in the app, so keep them in your copy.
Keep the copy up to date
Consultations have no updatedAt, and the filters only see creation time. A sync that stays correct works like this:
- Use windows that have ended. Sync up to the start of the current day (UTC). A closed window's rows never move between pages while you read them, which offset pagination would otherwise allow.
- Re-sync recent days. Notes are usually edited in the days after the visit. On each run, start the window a few days before the end of the previous run; the script below uses seven.
- Upsert on
id. The consultation ID is stable, so writing each consultation under its ID makes re-runs safe and never creates duplicates. - Detect changes by content. Hash each file, with trailing whitespace trimmed from clinical notes, and update only what changed.
- Run a full re-sync now and then. It picks up edits older than your re-sync window. Consultations erased on a clinic's request disappear from the list, so compare the IDs in each window with your copy if you need to mirror erasures.
Limits and performance
Each row costs six storage lookups, so a page of 50 takes noticeably longer than a page of 10; expect seconds per page. Run one request at a time per clinic, and use daily windows to keep pages small. Requests that run too long are cut off by the platform limits described in Limits.
Common errors
| Status | Message | Cause | Fix |
|---|---|---|---|
400 | Input validation failed | pageSize above 50, a date without time zone, or createdAtBefore not after createdAtFrom | Read issues[] and correct the parameter. |
403 | You are not an admin of this workspace | The key's user is not a clinic or organization admin | Use an admin's personal key. |
403 | This API key cannot access this endpoint | An organization or clinic key | Use a personal key. |
403 | You are not an admin of this consultation workspace | Single export of an archived or deleted consultation (check reason), or a non-admin | Use the list's URLs for that row. |
403 | Storage error (XML) on download | The URL is more than an hour old | Request the page again. |
404 | Consultation not found | Wrong consultation ID or clinic | Check both IDs. |
See Errors for the error format.
End-to-end script
The script copies a clinic into a folder, one subfolder per consultation, in daily windows. Run it nightly without dates for an incremental sync, or pass dates for a backfill. It needs Node.js 18 or later and nothing else.
// export-consultations.mjs: Node.js 18 or later, no dependencies.
// VETNIO_API_KEY=sk_live_… node export-consultations.mjs <workspaceId> <outputDir> [from] [before]
// Without from/before it re-syncs the last 7 days before the previous run's end, up to today 00:00 UTC.
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } 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, outputDir, fromArg, beforeArg] = process.argv.slice(2);
const ARTIFACTS = ["transcription", "clinicalNote", "structuredDocument", "communication", "referral", "audioRecording"];
const EXTENSIONS = { "application/json": "json", "text/html": "html", "text/plain": "txt", "audio/wav": "wav" };
const DAY = 24 * 60 * 60_000;
async function api(path) {
const response = await fetch(`${BASE_URL}${path}`, { headers: { Authorization: `Bearer ${API_KEY}` } });
const payload = await response.json();
if (!response.ok) throw new Error(`GET ${path} failed with ${response.status}: ${payload.message}`);
return payload;
}
async function writeAtomically(path, data) {
await writeFile(`${path}.tmp`, data);
await rename(`${path}.tmp`, path); // a re-run overwrites; it never duplicates
}
async function saveConsultation(row) {
const folder = join(outputDir, row.id);
await mkdir(folder, { recursive: true });
const { id, name, creatorId, createdAt, externalId, clinicalNoteTemplateId } = row;
const metadata = { id, name, creatorId, createdAt, externalId, clinicalNoteTemplateId, artifacts: {} };
for (const artifact of ARTIFACTS) {
if (!row[artifact]) continue;
const response = await fetch(row[artifact]);
if (!response.ok) return false; // the signed URL expired: fetch the page again
const type = (response.headers.get("content-type") ?? "").split(";")[0];
const fileName = `${artifact}.${EXTENSIONS[type] ?? "bin"}`;
const bytes = Buffer.from(await response.arrayBuffer());
// Clinical notes can end with whitespace that is not part of the note.
const content = artifact === "clinicalNote" ? bytes.toString("utf8").trimEnd() : bytes;
await writeAtomically(join(folder, fileName), content);
metadata.artifacts[artifact] = fileName;
}
await writeAtomically(join(folder, "consultation.json"), JSON.stringify(metadata, null, 2));
return true;
}
async function exportWindow(from, before) {
const query = (page) =>
`/workspaces/${workspaceId}/data-export/consultations?page=${page}&pageSize=50` +
`&createdAtFrom=${from.toISOString()}&createdAtBefore=${before.toISOString()}`;
for (let page = 1, totalPages = 1; page <= totalPages; page += 1) {
let result = await api(query(page));
totalPages = result.totalPages;
for (let index = 0; index < result.consultations.length; index += 1) {
if (await saveConsultation(result.consultations[index])) continue;
result = await api(query(page)); // the links expired: fetch the same page again
if (!(await saveConsultation(result.consultations[index]))) throw new Error("Download failed twice");
}
if (result.count > 0) console.log(`${from.toISOString().slice(0, 10)}: page ${page} of ${totalPages}`);
}
}
const statePath = join(outputDir, "sync-state.json");
await mkdir(outputDir, { recursive: true });
const state = JSON.parse(await readFile(statePath, "utf8").catch(() => "{}"));
const todayUtc = new Date(Math.floor(Date.now() / DAY) * DAY);
const before = beforeArg ? new Date(beforeArg) : todayUtc;
const from = fromArg
? new Date(fromArg)
: new Date((state.syncedUntil ? Date.parse(state.syncedUntil) : before.getTime() - 30 * DAY) - 7 * DAY);
// One window per UTC day keeps every page small and stable.
for (let start = from.getTime(); start < before.getTime(); start += DAY) {
await exportWindow(new Date(start), new Date(Math.min(start + DAY, before.getTime())));
}
const syncedUntil = Math.max(before.getTime(), state.syncedUntil ? Date.parse(state.syncedUntil) : 0);
await writeAtomically(statePath, JSON.stringify({ syncedUntil: new Date(syncedUntil).toISOString() }));The first run without dates copies the last 37 days. Remove "audioRecording" from ARTIFACTS if you do not keep audio.
Next steps
- Export usage: usage statistics for an organization and its clinics.
- Export configuration: templates, settings and Transcription terms.
- Records: record formats and the version history of each note.
- Consultations: archiving and the other consultation states.
Endpoints used in this guide
- GET
/workspaces/{workspaceId}/data-export/consultationsList consultations with export URLs - GET
/workspaces/{workspaceId}/consultations/{consultationId}/data-exportExport raw consultation data - GET
/workspacesList workspaces - GET
/organizations/{organizationId}Get an organization with its clinics, people and templates
Last reviewed against the API on 2026-10-26.