Errors
When a request fails you get a non-2xx HTTP status and a JSON body. There is no single error envelope: failures arrive in one of two different shapes depending on where in the pipeline the request was rejected. Build your client to read the response body on every status, success or failure, and to tolerate both shapes.
Status codes
Section titled “Status codes”| Status | Meaning |
|---|---|
| 200 | OK. The request succeeded and the body contains the response. |
| 201 | Created. Used by endpoints that create a new record. |
| 400 | Bad request. Validation failed, paging exceeded the 10,000 ceiling, or a business rule rejected the call. See “Handled exceptions” below. |
| 401 | Unauthorized. A required auth header is missing (including X-Clinic-Id), the API keys are missing or mismatched, or the integration is not enabled for the clinic. See “Common failure states” below. |
| 403 | Forbidden. The keys are valid but lack access to the requested clinic or resource. |
| 404 | Not found. The resource ID does not exist or is not visible to this integration. |
| 409 | Conflict. A concurrent modification or duplicate-key condition was detected. |
| 429 | Too many requests. You exceeded a rate limit. |
| 500 | Server error. Something went wrong on the Shepherd side. Retry with backoff. |
| 503 | Service unavailable. Shepherd is briefly offline for maintenance. Retry with backoff. |
The two error body shapes
Section titled “The two error body shapes”Both shapes are served as application/json. Which one you get depends on how far the request travelled before it was rejected.
1. A bare JSON string
Section titled “1. A bare JSON string”Rules enforced inside the endpoint (business rules, date-range limits, the paging ceiling) return the message as a JSON-encoded string: the body is a quoted string, not an object.
HTTP/1.1 400 Bad RequestContent-Type: application/json
"dateCreatedTo must be within one month of dateCreatedFrom"Parsing this yields a plain string, so JSON.parse(body) gives you the message directly. Note the surrounding quotes are part of the body; if you print the raw text without parsing, they will show up in your logs.
2. A validation envelope
Section titled “2. A validation envelope”Requests rejected before the endpoint runs, which mainly means model binding and missing required fields, return an object:
HTTP/1.1 400 Bad RequestContent-Type: application/json
{ "message": "The request is invalid.", "statusCode": 400, "details": null, "error": null, "modelState": null}A safe reader handles both: parse the body as JSON, and if the result is a string use it as the message, otherwise read .message.
Handled exceptions
Section titled “Handled exceptions”The API distinguishes between two flavors of 400:
- Validation errors: the request shape is wrong, a required field is missing, the date range is too wide, or the page math exceeds the 10,000 ceiling. A missing or unbindable field produces the envelope in shape 2; the rest produce the string in shape 1.
- Handled exceptions: the request was well-formed but a business rule rejected it (for example, posting an invoice line against a closed invoice). These return
400with the business message in shape 1. They are not transient; retrying without changes will fail again.
Treat 400 as “fix the request, then retry”; do not put a retry loop around it.
Common failure states
Section titled “Common failure states”Most support requests come down to a handful of conditions. The messages below arrive as bare JSON strings (shape 1 above), so what you get back reads much like the “Response body” column here. The fix is almost always client-side.
| Response body | Status | What it means | How to fix |
|---|---|---|---|
Missing X-Clinic-Id header | 401 | The clinic identifier was not sent. It is an HTTP header, not a body field; a clinicId placed in the JSON body is ignored. | Send X-Clinic-Id with the clinic’s UUID on every clinic-scoped call. See Authentication. |
Missing X-Integration-Public-Key header / Missing X-Integration-Private-Key header | 401 | One of the two key headers is absent. | Send both key headers on every request. |
Incorrect API keys provided | 401 | The public/private pair does not match a known integration, or a key was revoked. | Recheck the pair; rotate the key if you suspect exposure. |
Integration not enabled for the provided clinic | 401 | The keys are valid, but this integration has not been turned on for that clinic. | The clinic enables your integration on their side; partners confirm this during onboarding. |
| (no specific message) | 403 | Authenticated, but the keys are not scoped to the clinic or record you asked for. | Confirm the clinic is in your grant and that X-Clinic-Id names the right one. For shared data across a group, see Groups and multi-site. |
Request must contain either a DateCreated or DateUpdated filter | 400 | A list endpoint that requires a date window received none. | Send a dateCreatedFrom / dateCreatedTo or dateUpdatedFrom / dateUpdatedTo pair. |
dateCreatedTo must be within one month of dateCreatedFrom | 400 | The date range exceeds the one-month cap. | Keep each window to one calendar month; sweep month by month for longer spans. |
| (paging message) | 400 | page * rpp exceeded the 10,000-item ceiling. | Narrow the result set with a filter, then re-paginate. See Conventions. |
{"message":"The request is invalid."} | 400 | A required field was missing, or a value could not be bound to its type (malformed GUID, unparseable date). No field-level detail is returned. | Recheck the endpoint’s required fields and the format of every ID and date you sent. For patient writes see Troubleshooting a rejected write. |
Request rate limit exceeded. Please wait before making further requests. | 429 | You crossed the per-minute or per-hour rate limit. | Back off with jitter and retry; spread bulk work out. |
Retrying
Section titled “Retrying”A simple, robust strategy:
- Do not retry:
400,401,403,404,409. These are caller errors and a retry just wastes the rate-limit budget. - Retry with backoff:
429,500,503, network timeouts. Use exponential backoff with jitter. See Rate limits for guidance.
Reading the error body
Section titled “Reading the error body”The pattern below works for any endpoint. Inspect ok/IsSuccessStatusCode/getResponseCode, and on failure read the raw body; reading it as text first is safest, since it works whichever shape came back:
# -w prints the HTTP status on a final line, -s silences the progress barcurl -sS -X POST https://open-api.shepherd.vet/pav2/open-api-clients \ -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":1,"rpp":10}' \ -w "\nHTTP %{http_code}\n"import os, requests
resp = requests.post( "https://open-api.shepherd.vet/pav2/open-api-clients", 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": 1, "rpp": 10}, timeout=30,)
if not resp.ok: # Body is JSON: either a bare string or a {"message": ...} object. raise RuntimeError(f"Shepherd {resp.status_code}: {resp.text}")
data = resp.json()using System.Net.Http.Json;
using var http = new HttpClient();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 = 1, rpp = 10 });var resp = await http.PostAsync( "https://open-api.shepherd.vet/pav2/open-api-clients", body);
var text = await resp.Content.ReadAsStringAsync();if (!resp.IsSuccessStatusCode){ throw new HttpRequestException($"Shepherd {(int)resp.StatusCode}: {text}");}<?php$ch = curl_init('https://open-api.shepherd.vet/pav2/open-api-clients');curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, 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' => 1, 'rpp' => 10]),]);$response = curl_exec($ch);$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status < 200 || $status >= 300) { throw new RuntimeException("Shepherd $status: $response");}$data = json_decode($response, true);const resp = await fetch('https://open-api.shepherd.vet/pav2/open-api-clients', { 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: 1, rpp: 10 }),});
if (!resp.ok) { // Body is JSON: either a bare string or a { message: ... } object. const text = await resp.text(); throw new Error(`Shepherd ${resp.status}: ${text}`);}const data = await resp.json();- Authentication for the three headers, including the
X-Clinic-Idheader behind most401s. - Rate limits for the specifics of 429 handling and backoff.
- Conventions for the 10,000-item paging ceiling that produces a 400.
- Groups and multi-site for the clinic scoping behind
403s in shared-data groups.