If you’re trying to call the Shepherd Veterinary Software Open API and every documented endpoint returns a 404, or your record counts come back as 1 no matter what you filter on, this post is for you. Both are real, both are undocumented, and both fail quietly enough that you’ll blame your own code first. I did. And if you’re about to write to the client record, skip ahead to trap 6 before you do — that one deletes data.
This is a write-up of the first working session connecting to Shepherd’s Open API on behalf of a veterinary practice client. Everything below was verified against the live production API on August 19, 2026, spec version v2.244.0.38. Eight traps. Two cost me real time, one cost me a client record, and one turned out not to be a trap at all once I actually tested it.
First problem: you can’t search for this
Before the technical part, a warning about research. Search for “Shepherd API” and you will get confidently wrong answers about an entirely different product — there’s a Rails UI theme at shepherd.railsui.com with a “Shepherd Public API” for calendars, reservations, and properties, plus an unrelated machine-learning tool called Shepherd on GitHub.
I watched a search assistant fall into this in front of me while writing this post: asked about Shepherd’s vet API pagination, it cheerfully described the rental-property one. If an answer mentions reservations or properties, you’re reading about the wrong Shepherd. Shepherd Veterinary Software is at shepherd.vet, the practice app is app.shepherd.vet, and the API lives at open-api.shepherd.vet.
What the API actually is
Shepherd’s Open API is self-serve. It isn’t gated behind a support ticket or a partner program — a practice Admin turns it on and generates key pairs in the app under Admin → Integrations → Shepherd OpenAPI → Add New Set. Earlier notes I’d written said access was support-gated. That was wrong; it’s simply behind the practice login, which is why external recon never finds it.
The orienting facts, all verified:
- Production base URL:
https://open-api.shepherd.vet/pav2/ - Swagger spec:
https://open-api.shepherd.vet/pav2/swagger/v1— a plainGET, roughly 1.55 MB, no auth headers required. 133 paths. This is the single most useful thing you can fetch, and nothing tells you it’s public. - Every endpoint is
POST, including reads. There is noGETfor data. - Auth headers:
X-Integration-Public-KeyandX-Integration-Private-Key, plusX-Clinic-Idon every route except the clinics route — so you call clinics first to learn your own clinic id. - Rate limits: 12,000/hour and 800/minute, then
429.
One safety note before anything else: the key pairs are unscoped. There’s no read-only option and no per-resource scoping anywhere in the spec. A single key set grants full read and write across client PII, patient records, clinical SOAP notes, prescriptions, and invoices. Least privilege is your discipline, not a platform control. Treat the private key like a master credential.
Trap 1: every route is prefixed open-api-
The spec’s basePath is empty, and the resource names in the documentation are not the callable paths. The real paths carry an open-api- prefix:
# wrong - returns 404
POST https://open-api.shepherd.vet/pav2/clients
# right
POST https://open-api.shepherd.vet/pav2/open-api-clients
So it’s open-api-clients, open-api-appointments, open-api-invoices, open-api-clinics, and so on for all 133 paths.
What makes this cost more time than it should: the failure isn’t a JSON error. It’s a raw IIS error page.
<title>404 - File or directory not found.</title>
<h2>404 - File or directory not found.</h2>
<h3>The resource you are looking for might have been removed,
had its name changed, or is temporarily unavailable.</h3>
An HTML 404 from the web server, with no API error body, reads like “this endpoint doesn’t exist in your plan” or “your auth was rejected before routing.” I burned my first call chasing the wrong theory because of it. The tell is the response being HTML at all — if you get markup instead of JSON, you have a path problem, not a permissions problem.
Trap 2: rpp=1 silently breaks totalRecords
This one is worse, because it doesn’t error at all — it returns a plausible number that happens to be wrong.
List responses include a totalRecords field. The obvious way to get a cheap count is to request a single row and read the total. Every engineer reaches for this. On Shepherd it always returns 1.
Same filter, same date range, only rpp changing:
rpp | totalRecords returned |
|---|---|
| 1 | 1 — wrong |
| 2 | 233 |
| 5 | 233 |
| 10 | 233 |
| 100 | 233 |
| 1000 | 233 |
Anything from 2 upward reports correctly. Only rpp=1 lies, and it lies in the exact shape of a valid answer. Build a monthly report on that idiom and it reads “1” forever without ever throwing an error or logging anything.
Use rpp=2 when you want a count. It’s the cheapest request that tells the truth.
Trap 3: the marketing-consent field is spelled differently for reads and writes
This is the trap I got wrong, so it’s worth walking through what the spec says and what the server actually does — they aren’t the same thing, and the gap between them is the whole lesson.
The client object’s mass-email consent flag is spelled correctly when you read it, and missing an “i” when you write it. Straight from the spec definitions:
OpenApiClientController.Client -> emailMassCommunicationNotification
OpenApiClientController.ClientWriteModel -> emailMassCommuncationNotification
OpenApiClientController.ClientUpdateModel -> emailMassCommuncationNotification
Read model: Communication. Write and update models: Communcation. Same for the SMS equivalent — smsMassCommunicationNotification to read, smsMassCommuncationNotification to write.
The obvious conclusion — and the one I published in the first draft of this post — is that sending the correctly spelled key means the API accepts your request, returns success, and silently ignores the field. A compliance-shaped failure that reports itself as working.
I tested it, and that’s wrong. The API honours both spellings.
On a live client whose emailMassCommunicationNotification read true, I sent a full update containing the correctly spelled key set to false, with the misspelled key entirely absent from the body. HTTP 200, and the re-read came back false. The misspelled key works too — I’d already used it on six records before running this test.
There’s a control built into that result, which is worth spelling out because it’s what makes the test meaningful. Omitted scalar fields on this endpoint are preserved, not reset — see trap 6, where I verify that against two fields the update model can’t even express. So if the correctly spelled key had been ignored as an unknown property, the flag would have stayed true. It didn’t. The field was bound and applied.
Honest limits on that: one record, one direction, and I haven’t run a negative control with a deliberately nonsense field name to confirm the model binder rejects garbage. If you’re depending on this, spend the five minutes and verify it in your own tenant.
So trap 3 is a spec defect rather than a data hazard: the definitions genuinely disagree, and reading them side by side tells you nothing about what the server does. Use the misspelled write key if you want to match the published contract, or the correct one if it offends you less. Both land. What you should not do is what I did in the first version of this post — infer runtime behavior from a schema mismatch and write it up as fact.
Worth noting this survived a version bump — it’s present in both v2.243.0.31 (July 2026) and v2.244.0.38 (August 2026). Don’t assume it’ll be corrected soon. Also note the filter parameter uses the correct spelling (hasEmailMassCommunicationNotification), so a single sync job may legitimately need both spellings in different places.
Related: isSubscribedToNotifications and isSubscribedToSmsNotifications appear in the read model and as filters, but not in the write model — they look derived from the granular flags. To change opt-in state you write the granular fields, not these.
Trap 4: you cannot look a client up by email
The client filter model has no email-equality field. Here is the complete list of what you can filter on:
clientStatusIds, clinicIds, dateCreatedFrom, dateCreatedTo,
dateUpdatedFrom, dateUpdatedTo, embed, excludeIds,
hasEmailDirectMessagingNotification, hasEmailMassCommunicationNotification,
hasEmailNotification, hasReminderEmailNotification,
hasReminderPostcardNotification, hasReminderSmsNotification,
hasSmsDirectMessagingNotification, hasSmsMassCommunicationNotification,
hasSmsNotification, identifiers, ids, isDeleted,
isSubscribedToNotifications, isSubscribedToSmsNotifications,
page, patientIds, phoneNumbers, rpp, searchQuery, sort
There’s phoneNumbers, there’s identifiers, there’s free-text searchQuery — and no email. Any external system that identifies a person by email address (which is most of them, certainly every email marketing platform) has no clean key to match on. You’re reduced to a fuzzy text search and handling zero-or-many results yourself. Whether searchQuery even matches against the email field is something I haven’t verified yet.
One thing that does work, and that I wasted a call getting wrong: the multi-value filters take comma-separated strings, not JSON arrays. They’re typed string in the spec and it’s easy to skim past. Pass an array and you get a validation error that tells you nothing:
# wrong - looks like a broken filter
{"identifiers":["ABC1234"]}
-> {"message":"The request is invalid.",
"modelState":{"model.identifiers":["An error has occurred."]}}
# right
{"identifiers":"ABC1234"}
{"identifiers":"ABC1234,DEF5678,GHI9012"}
Same for ids, clientIds, patientIds and the rest. I logged this as a broken endpoint in my own notes before realizing it was my mistake — identifiers is a perfectly good lookup key, and if you can get the practice’s client identifier into your external system once, you have the clean join that email won’t give you.
Trap 5: collections come back empty unless you ask for them
Read a client with a plain filter and you get something like this:
"clientPhones": [],
"clientInfo": null,
"clientCoOwner": null,
"authorizedAgents": []
That particular client has two phone numbers, a full mailing address, and a co-owner. The empty arrays don’t mean “no data.” They mean “not fetched.” Nested objects only populate when you name them in the embed parameter:
# empty by omission
{"identifiers":"ABC1234","rpp":2}
-> "clientPhones": []
# the truth
{"identifiers":"ABC1234","rpp":2,
"embed":"clientPhones,clientInfo,clientCoOwner,authorizedAgents"}
-> "clientPhones": [ {...}, {...} ]
As lazy-loading conventions go this is unremarkable, and plenty of APIs do it. It only turns dangerous when you write that empty array back — which brings us to the one that actually cost me a record.
Trap 6: the update endpoint deletes what you leave out — except when it doesn’t
First the mechanism. Client updates go through a single endpoint, and it’s the one place in this API that isn’t a POST:
PUT /pav2/open-api-clients/{id}/update
body: OpenApiClientController.ClientUpdateModel
required: firstName, lastName, clientPhones
It’s a full replace. There is no PATCH and no preferences-scoped write anywhere in the spec — the entire write surface for clients is /open-api-clients/write to create and this to replace. Changing one boolean means resubmitting the whole client.
Now the part that isn’t documented anywhere. Fields you leave out of the body behave in two opposite ways depending on their type:
- Omitted scalars are preserved.
emailDirectMessagingNotificationandreminderSmsNotificationexist in the read model but aren’t inClientUpdateModelat all — there is no way to send them. Both survived a full PUT untouched. Appointment-reminder settings are not at risk. - Omitted sub-objects and collections are deleted.
clientCoOwner,clientPhones,clientInfo,authorizedAgents. Leave the key out and the data goes away.
I found that out by destroying a co-owner record. I built an update body that faithfully round-tripped every scalar, verified that omitted scalars were preserved, and then assumed collections worked the same way. They don’t. The PUT returned 200 and the co-owner was gone — clientCoOwner came back null, and open-api-client-co-owners dropped from one row to zero for that client. That one is mine: I generalized from a behavior I had verified to a different one I hadn’t.
What saved the rest of the batch is that I was diffing every field on every record against a pre-write snapshot and halting on the first unexpected change. It fired on record five. Without that gate the same run would have wiped the co-owner off every client in the batch that had one — which, in my working set, was more than a third of them.
There’s a second, quieter guard worth knowing about. clientPhones is validated as “The ClientPhones field must have exactly one primary phone.” My very first attempt at any of this sent clientPhones: [], harvested from a non-embedded read exactly as trap 5 describes, and Shepherd rejected it:
{"message":"The request is invalid.",
"modelState":{"model.ClientPhones":[
"The ClientPhones field must have exactly one primary phone."]}}
Validation runs before mutation, so a rejected PUT changes nothing. That rule is the only reason my first bad request didn’t strip the phone numbers off a few hundred clients. I’d rather my own checks had caught it, and I don’t recommend building on the assumption that a vendor’s unrelated constraint will keep catching you.
The rule: embed every collection on read, and write every collection back explicitly. Embedding alone protects nothing. You need both halves.
Trap 7: an empty array and a missing key mean different things
Having deleted a co-owner, I went to put it back. I couldn’t, and the reason is a decent piece of API irony.
ClientCoOwnerWriteModel.clientCoOwnerPhones carries the same validation as the client’s own phone list — exactly one primary phone. But a co-owner with no phone number is a completely normal record; every co-owner in my working set had zero. So the restore was rejected:
{"message":"The request is invalid.",
"modelState":{"model.ClientCoOwner.ClientCoOwnerPhones":[
"The ClientCoOwnerPhones field must have exactly one primary phone."]}}
The same validation that lets you delete the record by accident also stops you putting it back. For a few minutes I thought the only recovery path was the practice UI.
The fix is one key of JSON. Send the co-owner with clientCoOwnerPhones as an empty array and you’re rejected. Omit the key entirely and it works, preserving the phoneless state exactly:
# rejected
"clientCoOwner": {"firstName":"...","lastName":"...","clientCoOwnerPhones":[]}
# accepted - restores the record, no phones
"clientCoOwner": {"firstName":"...","lastName":"..."}
The record came back with first name, last name and email intact, zero phones as before, and open-api-client-co-owners back to one row. No placeholder phone number needed.
So in this API [] and absent are not synonyms, and which one you send is the difference between a recoverable mistake and a permanent one. Nothing in the spec hints at that.
Trap 8: referralSourceIds is writable but not readable
referralSourceIds is a field in ClientUpdateModel. You’re expected to send it. You cannot read it.
A client that definitely holds a referral link — confirmed independently through /pav2/open-api-client-referral-source — returns referralSourceIds: [] from the clients endpoint every time, with embed and without it. I tried every read variant I could construct.
Combine that with trap 6 and you have a field that a full-replace PUT is required to send, that has no read path to source the current value from, and that deletes what’s there if you send an empty array. The workaround is to pull the links from open-api-client-referral-source, group them by clientId, and rebuild the array yourself before every write.
What actually works
Start with clinics, because it’s the one route that doesn’t need X-Clinic-Id — it’s how you get it:
curl -X POST "https://open-api.shepherd.vet/pav2/open-api-clinics" \
-H "Content-Type: application/json" \
-H "X-Integration-Public-Key: YOUR_PUBLIC_KEY" \
-H "X-Integration-Private-Key: YOUR_PRIVATE_KEY" \
-d '{"rpp":10,"page":1}'
That returns your clinic record including its id. Every subsequent call carries that as X-Clinic-Id. A date-filtered read, with the count trap avoided:
curl -X POST "https://open-api.shepherd.vet/pav2/open-api-appointments" \
-H "Content-Type: application/json" \
-H "X-Integration-Public-Key: YOUR_PUBLIC_KEY" \
-H "X-Integration-Private-Key: YOUR_PRIVATE_KEY" \
-H "X-Clinic-Id: YOUR_CLINIC_ID" \
-d '{"dateFrom":"2026-08-01T00:00:00Z","dateTo":"2026-08-31T23:59:59Z","rpp":1000}'
Dates want full ISO 8601 with a timezone. Paging is rpp (max 1000) and page; sorting is a single string in "field|order" form. embed takes a comma-separated list of nested objects to expand.
Two things worth knowing about the data shape once you’re in. Appointments carry a schedulingMethod field distinguishing staff-booked from client-self-booked — the values I’ve observed are Internal and Pet portal, though I’ve only sampled a few months, so treat that enum as incomplete. And the write surface is larger than a quick read of the spec suggests; I found a referral-source/write endpoint that wasn’t in my own earlier inventory. Recount before you assert what’s writable.
What I’d check first next time
- Fetch the Swagger spec before writing a line of code. It’s public, it’s one
GET, and it’s the ground truth. I’d read my own summary notes first and they were wrong about the route names — the spec wasn’t. - If a response is HTML, it’s a path problem. Not auth, not permissions.
- Never trust a count from
rpp=1. On this API specifically; as a habit, generally. - Diff read models against write models before building any sync — then test the difference instead of reasoning about it. Reading the definitions side by side is how I found trap 3. Assuming I knew what the server did with it is how I published something wrong.
- Diff every field after every write, against a snapshot you took before it, and halt the run on the first unexpected change. This is the single control that turned trap 6 from a data-loss incident into one recoverable record.
- Embed every collection you intend to write back. A default read hands you empty arrays that look like real answers.
- Assume the key is a master credential and design your access discipline accordingly, because the platform won’t do it for you.
None of the above required vendor support, a partner agreement, or an NDA — just a practice login and a willingness to read a 1.55 MB spec file. If you’re evaluating whether Shepherd can feed your reporting or your CRM, the answer is yes, with sharper edges than the marketing suggests.
Verified against Shepherd Open API v2.244.0.38 on August 19–20, 2026, including live write operations against a production tenant. Vendor behavior changes; if you’re reading this much later, re-fetch the spec and check the field spellings before trusting any of it.
System Debriefs are drafted from working-session context by the harness that did the work, then reviewed and published by me.