Skip to content

Conventions

The Shepherd API is consistent across resources. Once you know the conventions on this page, every endpoint reads about the same.

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:

ResourceUpdate endpoint
ClientsPUT /pav2/open-api-clients/{id}/update
PatientsPUT /pav2/open-api-patients/{id}/update
AppointmentsPUT /pav2/open-api-appointments/{id}/update
Task entriesPUT /pav2/open-api-task-entries/update/{id}
Task notesPUT /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.

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.

The safe pattern, and the one we recommend for any synchronisation logic, is three steps:

  1. Read the current record by id, embedding every nested collection you are about to send back.
  2. Merge your changes into that full representation in memory.
  3. 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 fieldTake it from the record you read
appointment patientIdsappointmentPatients[].patientId
patient preferredProviderIdspatientPreferredProviders[].providerId (embed patientPreferredProviders)
patient referralSourceIdspatientReferralSources[].referralSourceId (embed patientReferralSources)
task assignedToUsersIdstaskAssignedToUsers[].user.id (embed taskAssignedToUsers)
task assignedToTeamsIdstaskAssignedToTeams[].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.

A few fields are deliberately exempt from the full-replace rule, either because they are immutable or because they are managed elsewhere:

ResourcePreserved when omittedBehaves differently
ClientclientStatusId, the clientInfo contact blockauthorizedAgents 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 entrytaskEntryPriorityStatusId, taskEntryStatusId, dateDueOmitted name, description, clientId, patientId, and assignments are cleared.
Task noteeverything except noteOnly 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.

Update success codes differ by resource. Treat any 2xx as success and don’t require a body.

UpdateSuccess response
Clients, patients200 OK with the updated record
Task entries200 OK with no body
Appointments, task notes204 No Content

A 404 means the {id} in the path does not resolve within the clinic named by X-Clinic-Id.

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:

  • item is always an array, even when there is one record.
  • totalRecords is the total across all pages, not just this page.
  • recordsPerPage is the page size the API actually used, which can be smaller than the rpp you asked for.
  • links is currently always empty. Do not depend on it.
  • sort, searchQuery, and embed echo what you sent in the request, not what the API applied. An echoed sort does not mean the sort was used (see Sorting).

Creates and updates return the record itself (or nothing), not the envelope.

Pagination is driven by two body fields:

FieldDefaultValid valuesNotes
page11 or more1-indexed.
rpp101 to 1000Records per page. 100 or less on teams, the schedule-template endpoints, physical exams, and SOAP discharge instructions.
  • rpp and page below 1, or null, are rejected with 400 The 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 rpp at 100 or less on those two.
  • An rpp above 1000 is not rejected; it is silently reduced to 1000. Compare recordsPerPage in the response to what you sent, and page on the value the API used.
  • Keep page * rpp at or below 10,000. Patient notes reject deeper pages with HTTP 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.

  • Id filters such as ids, clientIds, and patientIds are comma-separated strings of UUIDs, not JSON arrays. Every value must be a valid UUID: a malformed id produces a 500, not a 400. Keep lists to about 100 ids per request and batch the rest.
  • searchQuery is 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.

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.

EndpointAccepted but not appliedFilter with
POST /pav2/open-api-inventory/transaction-journalsdateCreatedFrom/To, dateUpdatedFrom/To, ids, excludeIds, productIdsdateFrom/dateTo (the transaction date, one month at most), inventoryProductSettingIds, userIds
POST /pav2/open-api-client-balances/balancesdateCreatedFrom/To, ids, excludeIds, embeddateUpdatedFrom/To, clientIds, clientClinicAccountIds
POST /pav2/open-api-provider-schedule-templatesdateCreatedFrom/To, dateUpdatedFrom/To, excludeIds, isDeletedstartDate/endDate, providerIds
POST /pav2/open-api-schedule-template-appointmentsdateCreatedFrom/To, dateUpdatedFrom/To, excludeIdsstartDate/endDate, scheduleTemplateIds
POST /pav2/open-api-schedule-templatesdateCreatedFrom/To, dateUpdatedFrom/To, excludeIdsids (there is no date filter)
POST /pav2/open-api-inventory-statusdateCreatedFrom/To, dateUpdatedFrom/Toids, searchQuery
POST /pav2/open-api-physical-examscreationDateFrom/creationDateTodateCreatedFrom/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.

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 rpp at 250 or less when embedding.
  • Keep rpp at 50 or less with these embeds: appointmentPatients.patient on appointments, soapTreatmentSchedulers on treatments, and clinics or clinicBodyMaps on 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.

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 by dateCreated, or by any field whose name contains id.
  • inventory-status: sorting by dateCreated or dateUpdated.

Most list endpoints accept date pairs:

  • dateCreatedFrom / dateCreatedTo
  • dateUpdatedFrom / 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.

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.

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).

The following pulls the second page of 100 patients updated in May 2026, sorted by most-recent update, with each patient’s breed inlined:

Terminal window
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"}'
  • Errors for the error body shapes and status codes.
  • Rate limits for how aggressive you can be with paging loops.