Conventions
The Shepherd API is consistent across resources. Once you know the conventions on this page, every endpoint reads about the same.
POST almost everywhere
Section titled “POST almost everywhere”Reads, searches, creates, and actions accept POST with Content-Type: application/json and a JSON object body, distinguished by the URL path. Send {} when there are no filters: an empty body or a JSON null fails with a 500, not a 400. There are no GET, PATCH, or DELETE verbs. See Overview for the reasoning.
The exception is updating an existing record. Five endpoints use PUT:
| Resource | Update endpoint |
|---|---|
| Clients | PUT /pav2/open-api-clients/{id}/update |
| Patients | PUT /pav2/open-api-patients/{id}/update |
| Appointments | PUT /pav2/open-api-appointments/{id}/update |
| Task entries | PUT /pav2/open-api-task-entries/update/{id} |
| Task notes | PUT /pav2/open-api-task-notes/update/{id} |
Note that the two task routes put update before the id. A wrong verb, or a path that does not start with /pav2/open-api-, returns 405.
Always use https. Plain http is redirected, and most HTTP clients turn a redirected POST into a GET, which then fails in confusing ways.
Updating records is a full replacement
Section titled “Updating records is a full replacement”Update endpoints are PUT, and they mean it. A PUT submits the complete representation of the record, so any editable field you omit is interpreted as an intentional change and is cleared: set to null, empty, or its default. It is not a partial update, and there is no PATCH.
This catches integrators out most often on collections and links. Omit patientIds on an appointment update and every patient is removed from the appointment; omit preferredProviderIds on a patient, or the assignments on a task, and those links are gone. If you send a client update whose clientCoOwner block has no clientCoOwnerPhones array, you are telling the API the co-owner has no phone numbers, and the existing ones are removed.
Read, merge, write
Section titled “Read, merge, write”The safe pattern, and the one we recommend for any synchronisation logic, is three steps:
- Read the current record by id, embedding every nested collection you are about to send back.
- Merge your changes into that full representation in memory.
- Write the merged whole back to the update endpoint.
POST read by id ──► apply your changes ──► PUT the complete object (with embeds) (in memory) (nothing omitted)Step 1 is where most data loss originates: if the read did not include a nested collection, the merged object will not contain it either, and the write will clear it. Embed defensively.
Some update fields have a different name, or a different shape, from the record you read. Map them in step 2:
| Update field | Take it from the record you read |
|---|---|
appointment patientIds | appointmentPatients[].patientId |
patient preferredProviderIds | patientPreferredProviders[].providerId (embed patientPreferredProviders) |
patient referralSourceIds | patientReferralSources[].referralSourceId (embed patientReferralSources) |
task assignedToUsersIds | taskAssignedToUsers[].user.id (embed taskAssignedToUsers) |
task assignedToTeamsIds | taskAssignedToTeams[].team.id (embed taskAssignedToTeams) |
There is no optimistic concurrency: the last write wins. If staff edit the record between your read and your write, your write overwrites their change, so read immediately before writing.
Fields that survive omission
Section titled “Fields that survive omission”A few fields are deliberately exempt from the full-replace rule, either because they are immutable or because they are managed elsewhere:
| Resource | Preserved when omitted | Behaves differently |
|---|---|---|
| Client | clientStatusId, the clientInfo contact block | authorizedAgents is an edit list (below). Home clinic, system identifiers, and deleted status cannot be changed here. |
| Patient | (none) | The client association and weight cannot be changed here; add weight entries instead. |
| Appointment | (none) | An omitted appointmentStatusId resets the status to upcoming. |
| Task entry | taskEntryPriorityStatusId, taskEntryStatusId, dateDue | Omitted name, description, clientId, patientId, and assignments are cleared. |
| Task note | everything except note | Only note can change. |
authorizedAgents on a client update is a further exception: it is an explicit edit list rather than a replacement. Each entry adds an agent (omit id), edits one in place (include id), or removes one (set authorizedAgentIdForRemove with a removalNote). Agents you do not reference are left untouched.
Everything not listed above follows the full-replace rule.
Responses
Section titled “Responses”Update success codes differ by resource. Treat any 2xx as success and don’t require a body.
| Update | Success response |
|---|---|
| Clients, patients | 200 OK with the updated record |
| Task entries | 200 OK with no body |
| Appointments, task notes | 204 No Content |
A 404 means the {id} in the path does not resolve within the clinic named by X-Clinic-Id.
The collection envelope
Section titled “The collection envelope”List endpoints return a wrapper, not a bare array. The shape is:
{ "item": [ /* the records on this page */ ], "totalRecords": 1432, "page": 1, "recordsPerPage": 10, "sort": "dateCreated|desc", "searchQuery": null, "embed": "clients,clientPhones.phoneType", "links": []}A few things worth noting:
itemis always an array, even when there is one record.totalRecordsis the total across all pages, not just this page.recordsPerPageis the page size the API actually used, which can be smaller than therppyou asked for.linksis currently always empty. Do not depend on it.sort,searchQuery, andembedecho what you sent in the request, not what the API applied. An echoedsortdoes not mean the sort was used (see Sorting).
Creates and updates return the record itself (or nothing), not the envelope.
Pagination
Section titled “Pagination”Pagination is driven by two body fields:
| Field | Default | Valid values | Notes |
|---|---|---|---|
page | 1 | 1 or more | 1-indexed. |
rpp | 10 | 1 to 1000 | Records per page. 100 or less on teams, the schedule-template endpoints, physical exams, and SOAP discharge instructions. |
rppandpagebelow 1, ornull, are rejected with400The request is invalid.- Some records are large. Physical exams and SOAP discharge instructions run about 12 KB to 20 KB per record, so a 1000-record page is over 10 MB. Keep
rppat 100 or less on those two. - An
rppabove 1000 is not rejected; it is silently reduced to 1000. ComparerecordsPerPagein the response to what you sent, and page on the value the API used. - Keep
page * rppat or below 10,000. Patient notes reject deeper pages withHTTP 400; on other endpoints deep pages are not rejected but get slower with every page. Either way, the fix for a large result set is a narrower filter (typically a shorter date window), not deeper paging.
Stop paging when page * rpp reaches totalRecords or a page comes back empty. When paging a date window, fix both ends of the window before the first page, so records changing mid-run do not shift between pages.
Filtering
Section titled “Filtering”- Id filters such as
ids,clientIds, andpatientIdsare comma-separated strings of UUIDs, not JSON arrays. Every value must be a valid UUID: a malformed id produces a500, not a400. Keep lists to about 100 ids per request and batch the rest. searchQueryis free text; what it matches depends on the endpoint. At most 500 characters and 10 words.- Unknown fields are ignored, not rejected. A misspelled filter name is silently dropped, so the request runs without that filter. Copy field names from the schema exactly.
- A few endpoints ignore fields their schema lists. See Fields an endpoint does not apply.
Fields an endpoint does not apply
Section titled “Fields an endpoint does not apply”The schema for most list endpoints includes the standard date pairs and id filters. On the endpoints below, some of those fields are accepted without an error but are not applied, so they do not narrow the result. A request bounded only by one of them returns the endpoint’s full, unfiltered set.
| Endpoint | Accepted but not applied | Filter with |
|---|---|---|
POST /pav2/open-api-inventory/transaction-journals | dateCreatedFrom/To, dateUpdatedFrom/To, ids, excludeIds, productIds | dateFrom/dateTo (the transaction date, one month at most), inventoryProductSettingIds, userIds |
POST /pav2/open-api-client-balances/balances | dateCreatedFrom/To, ids, excludeIds, embed | dateUpdatedFrom/To, clientIds, clientClinicAccountIds |
POST /pav2/open-api-provider-schedule-templates | dateCreatedFrom/To, dateUpdatedFrom/To, excludeIds, isDeleted | startDate/endDate, providerIds |
POST /pav2/open-api-schedule-template-appointments | dateCreatedFrom/To, dateUpdatedFrom/To, excludeIds | startDate/endDate, scheduleTemplateIds |
POST /pav2/open-api-schedule-templates | dateCreatedFrom/To, dateUpdatedFrom/To, excludeIds | ids (there is no date filter) |
POST /pav2/open-api-inventory-status | dateCreatedFrom/To, dateUpdatedFrom/To | ids, searchQuery |
POST /pav2/open-api-physical-exams | creationDateFrom/creationDateTo | dateCreatedFrom/To |
A few small reference endpoints also ignore a field: excludeIds on clinics and the credit-balance aging report, ids on groups (the response is always the group of the clinic in X-Clinic-Id), isDeleted on locations, and embed, ids, and excludeIds on messaging settings.
Embedding related entities
Section titled “Embedding related entities”Most endpoints accept an embed field with comma-separated dot-paths. The API inlines the named relations into the response instead of returning only their IDs.
{ "page": 1, "rpp": 50, "embed": "clients,clientPhones.phoneType"}Dot-paths walk relationships. clientPhones.phoneType inlines the phones on each client and the phone-type lookup record for each phone. Without an embed, related objects come back null or [], while the ...Id fields are always present. Unknown embed names are ignored.
Embeds multiply the work the API does per record, so be deliberate:
- Embed only what your code reads, one or two levels deep.
- Keep
rppat 250 or less when embedding. - Keep
rppat 50 or less with these embeds:appointmentPatients.patienton appointments,soapTreatmentSchedulerson treatments, andclinicsorclinicBodyMapson groups. - For stable reference data (breeds, statuses, phone types), read the lookup once, cache it, and join in memory instead of embedding it on every request.
Sorting
Section titled “Sorting”Sort with the sort field. The format is field|direction, where direction is asc or desc:
{ "sort": "dateCreated|desc" }Only one sort key at a time is honored. Sort on top-level fields of the returned record.
Most endpoints do not reject a sort they cannot apply. A field that doesn’t exist, or a direction other than asc or desc, returns 200 with the records in the endpoint’s default order, and the envelope’s sort still shows what you sent. In a paged sync, a misspelled sort quietly changes which records land on which page, so check sort field names against the schema. A few endpoints do reject: some with 400, and two with 501:
client-balances/balances: sorting bydateCreated, or by any field whose name containsid.inventory-status: sorting bydateCreatedordateUpdated.
Date-range filtering
Section titled “Date-range filtering”Most list endpoints accept date pairs:
dateCreatedFrom/dateCreatedTodateUpdatedFrom/dateUpdatedTo
Some resources add their own, such as dateFrom / dateTo for an appointment’s scheduled time (on appointments, dateCreatedFrom / dateCreatedTo filter when the booking was made, not when the visit is). A few endpoints use only their own pair and ignore the standard ones; see Fields an endpoint does not apply.
Send both ends of a pair, and keep each window to one calendar month or less. To pull a longer span, sweep month by month. Patient notes enforce this: they reject a request with no date pair, with only one end of a pair, or with a window wider than one month, even when the request also filters by ids or patientIds. A window that ends before it starts is rejected with a 400 (except on the endpoints that do not apply that pair, where it is ignored like the rest of the pair). Other endpoints accept wider or open-ended windows, but those are the requests that get slow on large clinics, so treat the same rule as required everywhere you list clinical or financial data.
Timestamps are ISO 8601 in UTC, for example 2026-04-01T00:00:00Z. Convert clinic-local days to UTC bounds yourself.
Soft deletes
Section titled “Soft deletes”Deleted records are usually soft-deleted, and list endpoints return them by default. On clients, patients, appointments, invoices, and the other resources whose filter has an isDeleted field, omitting it includes deleted records. Send "isDeleted": false for a live view; a sync that must propagate deletions should read them and apply the flag.
The flag on the returned record is deleted on some resources (clients, patients, SOAP records, treatments) and isDeleted on others (appointments, invoices, payments, products, inventory, reminders, tasks). Check the schema for the resource you read.
Timestamps
Section titled “Timestamps”Every record includes:
dateCreated: when the record was first created, ISO 8601 UTC.dateUpdated: when the record was last modified, ISO 8601 UTC.
Use dateUpdated for incremental syncs (paired with dateUpdatedFrom/dateUpdatedTo).
Example: paginating patients
Section titled “Example: paginating patients”The following pulls the second page of 100 patients updated in May 2026, sorted by most-recent update, with each patient’s breed inlined:
curl -X POST https://open-api.shepherd.vet/pav2/open-api-patients \ -H "Content-Type: application/json" \ -H "X-Integration-Public-Key: $SHEPHERD_PUBLIC_KEY" \ -H "X-Integration-Private-Key: $SHEPHERD_PRIVATE_KEY" \ -H "X-Clinic-Id: $SHEPHERD_CLINIC_ID" \ -d '{"page":2,"rpp":100,"sort":"dateUpdated|desc","embed":"breed","dateUpdatedFrom":"2026-05-01T00:00:00Z","dateUpdatedTo":"2026-06-01T00:00:00Z"}'import os, requests
resp = requests.post( "https://open-api.shepherd.vet/pav2/open-api-patients", headers={ "Content-Type": "application/json", "X-Integration-Public-Key": os.environ["SHEPHERD_PUBLIC_KEY"], "X-Integration-Private-Key": os.environ["SHEPHERD_PRIVATE_KEY"], "X-Clinic-Id": os.environ["SHEPHERD_CLINIC_ID"], }, json={ "page": 2, "rpp": 100, "sort": "dateUpdated|desc", "embed": "breed", "dateUpdatedFrom": "2026-05-01T00:00:00Z", "dateUpdatedTo": "2026-06-01T00:00:00Z", }, timeout=30,)resp.raise_for_status()data = resp.json()print(f"Page {data['page']} of {-(-data['totalRecords'] // data['recordsPerPage'])}")for patient in data["item"]: print(patient["id"], (patient.get("breed") or {}).get("name"))using System.Net.Http.Json;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };http.DefaultRequestHeaders.Add("X-Integration-Public-Key", publicKey);http.DefaultRequestHeaders.Add("X-Integration-Private-Key", privateKey);http.DefaultRequestHeaders.Add("X-Clinic-Id", clinicId);
var body = JsonContent.Create(new{ page = 2, rpp = 100, sort = "dateUpdated|desc", embed = "breed", dateUpdatedFrom = "2026-05-01T00:00:00Z", dateUpdatedTo = "2026-06-01T00:00:00Z",});var resp = await http.PostAsync( "https://open-api.shepherd.vet/pav2/open-api-patients", body);resp.EnsureSuccessStatusCode();var json = await resp.Content.ReadAsStringAsync();Console.WriteLine(json);<?php$ch = curl_init('https://open-api.shepherd.vet/pav2/open-api-patients');curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'X-Integration-Public-Key: ' . getenv('SHEPHERD_PUBLIC_KEY'), 'X-Integration-Private-Key: ' . getenv('SHEPHERD_PRIVATE_KEY'), 'X-Clinic-Id: ' . getenv('SHEPHERD_CLINIC_ID'), ], CURLOPT_POSTFIELDS => json_encode([ 'page' => 2, 'rpp' => 100, 'sort' => 'dateUpdated|desc', 'embed' => 'breed', 'dateUpdatedFrom' => '2026-05-01T00:00:00Z', 'dateUpdatedTo' => '2026-06-01T00:00:00Z', ]),]);$response = curl_exec($ch);curl_close($ch);$data = json_decode($response, true);foreach ($data['item'] as $patient) { echo $patient['id'] . "\t" . ($patient['breed']['name'] ?? '') . "\n";}const resp = await fetch('https://open-api.shepherd.vet/pav2/open-api-patients', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Integration-Public-Key': process.env.SHEPHERD_PUBLIC_KEY, 'X-Integration-Private-Key': process.env.SHEPHERD_PRIVATE_KEY, 'X-Clinic-Id': process.env.SHEPHERD_CLINIC_ID, }, body: JSON.stringify({ page: 2, rpp: 100, sort: 'dateUpdated|desc', embed: 'breed', dateUpdatedFrom: '2026-05-01T00:00:00Z', dateUpdatedTo: '2026-06-01T00:00:00Z', }), signal: AbortSignal.timeout(30_000),});if (!resp.ok) throw new Error(`Shepherd ${resp.status}: ${await resp.text()}`);const data = await resp.json();console.log(`Page ${data.page} of ${Math.ceil(data.totalRecords / data.recordsPerPage)}`);for (const patient of data.item) { console.log(patient.id, patient.breed?.name);}- Errors for the error body shapes and status codes.
- Rate limits for how aggressive you can be with paging loops.