Workflows
Export usage statistics
Pull your organization's or clinic's usage statistics into a BI tool or data warehouse with a read-only organization or clinic key.
This guide is for clinic groups and their BI partners who want Vetnio's usage figures in a dashboard or data warehouse: consultations, recording hours, active vets, seats, VetsGPT use, call volume and note quality, per clinic, per vet or per consultation. The API returns the same table as the usage export in the app, as JSON, so the numbers match what organization admins see. It never returns patient records, notes, transcripts, audio or chat content.
Before you start
- An organization is a group of clinics in Vetnio, such as a clinic chain. You need an organization admin to create the key.
- You call the API with an organization key (
sk_org_…), which sees every clinic in the organization, or a clinic key (sk_ws_…), which sees one clinic. Both are read-only and work on three data endpoints only. A personal key of an organization admin also works, but a read-only key is the safer choice for a BI tool. See Authentication. - These keys are limited to 60 requests per minute and 10,000 per day.
All requests go to https://vetnio.com/api/v2 and use GET.
1. Create a key
- In Vetnio, open Organization and select the API tab.
- Choose Create API key. Name it after the system that will use it, for example Data warehouse sync.
- Under Access, choose Whole organization or one clinic. Under Expires, choose how long the key should live.
- Copy the key from the dialog; it is shown only once. Share it through a password manager, not by email or chat.
2. Check the key
GET/api-key confirms that the key works and returns the organizationId you need for the other two endpoints:
curl https://vetnio.com/api/v2/api-key \
-H "Authorization: Bearer $VETNIO_ORG_KEY"{
"name": "Data warehouse sync",
"organizationId": "83222a0a-688b-413e-9761-146f7a173b58",
"scope": {
"type": "organization",
"id": "83222a0a-688b-413e-9761-146f7a173b58"
},
"status": "active",
"createdAt": "2026-10-04T16:27:25.619Z",
"expiresAt": null,
"lastUsedAt": "2026-10-04T16:27:32.247Z",
"limits": { "requestsPerMinute": 60, "requestsPerDay": 10000 },
"usage": {
"requestsToday": 1,
"requestsLast30Days": 1,
"daily": [{ "day": "2026-10-04", "requests": 1 }]
}
}For a clinic key, scope.type is workspace and scope.id is the clinic's ID. Watch status and expiresAt in your monitoring: expiring means the key stops working at expiresAt.
3. Look up clinics and people
GET/organizations/{organizationId} returns the organization, its clinics, its people with their roles, and its organization-level templates. Use it to show clinic and vet names in your reports and to choose clinics for the usage request.
curl https://vetnio.com/api/v2/organizations/83222a0a-688b-413e-9761-146f7a173b58 \
-H "Authorization: Bearer $VETNIO_ORG_KEY"{
"organization": {
"id": "83222a0a-688b-413e-9761-146f7a173b58",
"name": "DEMO Clinic Group"
},
"workspaces": [
{ "id": "demo-central-clinic", "name": "DEMO Central Clinic", "city": null, "countryCode": "SE", "createdAt": "2026-09-08T09:00:00.000Z" },
{ "id": "demo-north-clinic", "name": "DEMO North Clinic", "city": null, "countryCode": "SE", "createdAt": "2026-09-08T09:00:00.000Z" }
],
"members": [
{
"userId": "c1b0f2a0-f819-5fcc-a2ed-85db0cef66a7",
"name": "Alex Demo",
"email": "demo1@example.test",
"organizationRole": "admin",
"workspaces": [
{ "workspaceId": "demo-central-clinic", "role": "admin" },
{ "workspaceId": "demo-north-clinic", "role": "admin" }
]
},
{
"userId": "09f50862-b169-5988-a089-838bb7b579a2",
"name": "Sam Demo",
"email": "demo2@example.test",
"organizationRole": null,
"workspaces": [
{ "workspaceId": "demo-central-clinic", "role": "member" },
{ "workspaceId": "demo-north-clinic", "role": "member" }
]
}
],
"templates": []
}Clinics are sorted by name and people by email. members lists everyone in the organization plus the staff of its clinics, once per person; organizationRole is null for clinic staff who are not organization members. A clinic's id is its workspace ID, the same value the usage export shows in the Clinic ID column and accepts in workspaceIds.
With a clinic key, workspaces and clinic staff are limited to that clinic, but organization members and organization templates are still listed.
4. Request the usage table
GET/organizations/{organizationId}/data-export/usage returns the usage table. This request asks for September 2026, one row per clinic, with four columns:
curl "https://vetnio.com/api/v2/organizations/83222a0a-688b-413e-9761-146f7a173b58/data-export/usage?startDate=2026-09-01&endDate=2026-09-30&grain=clinic&timePeriod=monthly&columns=workspaceName,consultationCount,patients,recordingHours" \
-H "Authorization: Bearer $VETNIO_ORG_KEY"{
"headers": ["Date", "Clinic Name", "Consultations", "Patients", "Recording Hours"],
"rows": [
["2026-09-01", "DEMO Central Clinic", 3, 3, 0.03],
["2026-09-01", "DEMO North Clinic", 3, 3, 0.03]
],
"start": "2026-09-01",
"end": "2026-09-30",
"totalRows": 2,
"page": 1,
"totalPages": 1
}All parameters are optional:
| Parameter | Default | Values | Meaning |
|---|---|---|---|
days | 30 | 1 to 366 | A rolling window that ends now: today plus the previous days - 1 days. |
startDate, endDate | YYYY-MM-DD | A fixed range in UTC, both days included. Replaces days. endDate is only used together with startDate; without it the range runs to now. At most 366 days. | |
grain | vet | vet, clinic, snapshot, consultation | What one row represents (below). |
timePeriod | monthly | daily, weekly, monthly, yearly | The size of each time bucket for vet and clinic. Weeks start on Monday. Ignored for the other grains. |
workspaceIds | Every clinic | Comma-separated clinic IDs | Only these clinics. Every ID must belong to the organization, or to the clinic key's own clinic. |
columns | Every column except note quality | Comma-separated column keys | Which columns to return (below). |
page, pageSize | 1, 1,000 | pageSize up to 5,000 | Only for grain=consultation. |
Choose the row level
grain | One row per | Notes |
|---|---|---|
vet | Vet, clinic and time bucket | Only vets with activity in the bucket get a row. A vet who works in two clinics gets a row for each. Sorted by date, clinic name, then vet email. |
clinic | Clinic and time bucket | Every clinic in scope gets a row, zero-filled, for each bucket in which any clinic had activity. Sorted by date, then clinic name. |
snapshot | Clinic, for the whole period | No Date column. Use it for billing and seat columns, which describe each clinic as it is today. |
consultation | Consultation | Includes deleted consultations, flagged in the Deleted column. Oldest first. The only paginated grain. |
Choose the columns
columns takes the keys below. The response always lists columns in this order, whatever order you request them in. Columns that do not apply to the chosen grain are left out without an error; an unknown key returns 400 with the list of valid keys.
| Group | Keys | Available at |
|---|---|---|
| Clinic | workspaceId, workspaceName, countryCode | Every grain |
| Clinic | clinicCreatedAt, clinicMembers, clinicActiveVets | vet, clinic, snapshot |
| Address and contact | street, city, stateProvince, zipCode, phone, taxId | vet, clinic, snapshot |
| Subscription and billing | subscriptionStatus, billingType, licensedSeats, usedSeats, unusedSeats, overageSeats, billingInterval, monthlyAmount, currency, nextPaymentDate, nextPaymentAmount, lastPaymentDate, lastPaymentAmount, isPastDue, cancelAtPeriodEnd, trialExpiresAt | vet, clinic, snapshot |
| Vet | userId, userName, userEmail | vet, consultation |
| Usage | consultationCount, patients, recordingHours, avgRecordingDurationMinutes, consultationsPerVet, lastActiveAt | vet, clinic, snapshot |
| Note quality | avgReadingSeconds, avgEditingSeconds, acceptanceRate, draftOutcome | vet, clinic, snapshot; only when listed |
| VetsGPT | vetsGptConversationsCreated, vetsGptMessagesSent | vet, clinic, snapshot |
| Telephony | callsTotal, callsIncoming, callsOutgoing, callMinutes, avgCallSeconds | vet, clinic, snapshot |
| Consultation | consultationId, consultationName, pimsConsultationId, consultationDate, consultationStartTime, consultationEndTime, consultationRecordingMinutes, consultationDeleted | consultation |
The Columns list in the app's Export dialog shows a label for each key. patients and the note-quality columns are the slowest to compute; leave them out when you do not need them.
5. Read the response
| Field | Meaning |
|---|---|
headers | Column titles, in order. For vet and clinic, the first column is always Date. |
rows | One array per row, with one value per header. Values are strings, numbers or null. |
start, end | The first and last day covered, YYYY-MM-DD (UTC). |
totalRows | Rows in the whole result. With grain=consultation, all pages together. |
page, totalPages | Paging for grain=consultation; both are 1 for the other grains. |
Find each column by its title in headers rather than by a fixed position: requesting other columns, or a column added to the export later, shifts positions. Titles are display names such as Clinic Name and Recording Hours.
How values are written:
Date: the first day of the bucket,YYYY-MM-DD. A bucket that starts before the range shows the range's first day instead, so a monthly row for a range starting 21 September reads2026-09-21.- Other dates, such as
Clinic Created,Last ActiveandTrial Ends:YYYY-MM-DD. Payment dates are ISO-8601 timestamps. - Consultation date and times: the clinic's local time, based on its country.
End Timeis an estimate, start time plus recording length, and its header says so. - Yes/no columns, such as
Past DueandDeleted: the stringsYesandNo. Draft Kept (%): 0 to 100 with one decimal. Money: two decimals, inCurrency.null: no value, for example no address on file or no payment yet.
6. Page through consultation rows
With grain=consultation, request page=1, 2, … until page equals totalPages:
curl "https://vetnio.com/api/v2/organizations/83222a0a-688b-413e-9761-146f7a173b58/data-export/usage?startDate=2026-09-01&endDate=2026-10-04&grain=consultation&pageSize=2&page=1&columns=workspaceName,userEmail,consultationId,pimsConsultationId,consultationDate,consultationStartTime,consultationRecordingMinutes,consultationDeleted" \
-H "Authorization: Bearer $VETNIO_ORG_KEY"{
"headers": [
"Clinic Name", "Vet Email", "Consultation ID", "PIMS Consultation ID",
"Consultation Date", "Start Time", "Recording (min)", "Deleted"
],
"rows": [
["DEMO Central Clinic", "demo1@example.test", "50052749-4a99-513b-a23a-a6997a28bbba", "Not opened from a PIMS", "2026-09-08", "11:00", 0.5, "No"],
["DEMO North Clinic", "demo1@example.test", "dfd6e768-51dd-5fcb-a786-c6dffe511d5f", "Not opened from a PIMS", "2026-09-08", "11:00", 0.5, "No"]
],
"start": "2026-09-01",
"end": "2026-10-04",
"totalRows": 23,
"page": 1,
"totalPages": 12
}Use pageSize=5000 in production to keep the number of requests low. Pages that start past row 50,000 are refused with 400; for larger results, split the date range, for example one request series per month. PIMS Consultation ID holds the consultation's ID in the practice system when it was opened from one.
Note-quality columns
avgReadingSeconds, avgEditingSeconds, acceptanceRate and draftOutcome describe how vets work with Vetnio's drafts: time spent reading and editing, the share of the draft kept, and an overall outcome (usedAsWritten, readClosely, rewrittenFast or heavyRewriting). They are only included when you list them in columns, because each such request runs an extra analytics query.
An organization or clinic key may make 20 requests with note-quality columns per UTC day. The 21st returns 429:
{
"message": "Daily limit of 20 requests with note-quality columns (avgReadingSeconds, avgEditingSeconds, acceptanceRate, draftOutcome) reached. Leave those columns out, or retry after 2026-10-05T00:00:00.000Z.",
"code": "TOO_MANY_REQUESTS",
"data": { "code": "TOO_MANY_REQUESTS", "httpStatus": 429, "path": "dataExport.organization.usage", "zodError": null }
}Requests without these columns are not affected, so fetch note quality once a day in a separate request. The values are null when there is no data for the row, and they are not available with grain=consultation.
Build the request in the app
The quickest way to get a request right is to build it in Vetnio. On the Organization page, choose Export in the bar at the top, pick the period, clinics, row level (One row per) and columns, then choose Use via API. Vetnio shows the curl command for exactly that selection, with the column keys filled in. Replace the placeholder key with yours.
Nightly sync and BI tips
- Re-fetch an overlap. Figures for recent days can still change, for example when a recording finishes uploading. Each night, fetch the last 7 days, or the current month with
timePeriod=monthly, and replace those rows in your store. - Use stable keys. Store rows keyed by
Date,Clinic IDand, at vet grain,Vet ID. Names and emails can change; IDs do not. - BI tools. Any tool that can call a JSON web API with a custom header works: send
Authorization: Bearer <key>, then expandrowsand take the column names fromheaders. Keep the key in the tool's credential store, not in the query text. - Audit trail. Every overview and usage request is recorded in Vetnio's audit log with the number of rows returned.
Common errors
| Status | Message | Cause | Fix |
|---|---|---|---|
400 | Unknown columns: …. Valid columns: … | A key in columns is misspelled. | Use a key from the column table. |
400 | A request covers at most 366 days. Split longer periods into several requests. | The date range is too long. | Split it. |
400 | startDate must be on or before endDate. | The dates are swapped. | Swap them. |
400 | Some workspaces do not belong to this organization | workspaceIds lists a clinic outside the organization, or another clinic than a clinic key's own. | Take IDs from step 3. |
400 | Pages starting past row 50000 are not served. … | page × pageSize is too far. | Split the date range. |
400 | Call this endpoint with an organization or workspace API key | GET /api-key was called with a personal key. | Use an organization or clinic key. |
401 | This action requires an authenticated user | The key is wrong, revoked or expired, or its creator lost their admin role. | Check the key under Organization → API. |
403 | You are not a member of this organization | The organizationId is not the key's organization. | Use organizationId from GET /api-key. |
403 | This API key cannot access this endpoint | The key was used on another endpoint. | These keys only work on the endpoints in this guide. |
429 | Rate limit of 60 requests per minute reached. … | Too many requests. | Wait as the message says; see Limits. |
Complete script
This Python script syncs one month of vet-level usage and the consultation list for that month, and handles the rate limit. It uses only the standard library.
import json, os, re, time, urllib.error, urllib.parse, urllib.request
BASE = "https://vetnio.com/api/v2"
KEY = os.environ["VETNIO_ORG_KEY"]
def get(path, **params):
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
request = urllib.request.Request(f"{BASE}{path}?{query}", headers={"Authorization": f"Bearer {KEY}"})
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=660) as response:
return json.load(response)
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8", "replace")
try:
message = json.loads(raw).get("message", raw)
except ValueError:
message = raw # platform errors such as 504 are not JSON
wait = re.search(r"Retry in (\d+) seconds", message)
if error.code == 429 and wait:
time.sleep(int(wait.group(1)) + 1)
continue
if error.code >= 500 and attempt < 4:
time.sleep(2 ** attempt)
continue
raise RuntimeError(f"{error.code} {message}") from None
raise RuntimeError("Gave up after repeated rate limiting")
def as_records(table):
return [dict(zip(table["headers"], row)) for row in table["rows"]]
key = get("/api-key")
organization_id = key["organizationId"]
if key["status"] == "expiring":
print(f"Warning: this key stops working at {key['expiresAt']}")
overview = get(f"/organizations/{organization_id}")
clinic_names = {clinic["id"]: clinic["name"] for clinic in overview["workspaces"]}
usage = get(
f"/organizations/{organization_id}/data-export/usage",
startDate="2026-09-01", endDate="2026-09-30", grain="vet", timePeriod="daily",
columns="workspaceId,userId,userEmail,consultationCount,recordingHours,vetsGptMessagesSent",
)
vet_rows = as_records(usage)
consultations, page = [], 1
while True:
result = get(
f"/organizations/{organization_id}/data-export/usage",
startDate="2026-09-01", endDate="2026-09-30", grain="consultation",
columns="workspaceId,userId,consultationId,pimsConsultationId,consultationDate,consultationStartTime,consultationRecordingMinutes,consultationDeleted",
page=page, pageSize=5000,
)
consultations.extend(as_records(result))
if result["page"] >= result["totalPages"]:
break
page += 1
print(f"{len(clinic_names)} clinics, {len(vet_rows)} vet-day rows, {len(consultations)} consultations")Next steps
- Authentication: rotate the key without downtime and keep it safe.
- Limits: rate limits and what counts toward them.
- Export configuration: settings and templates, for a personal key of an admin.
- The Organizations and usage reference.
Endpoints used in this guide
Last reviewed against the API on 2026-10-13.