Errors
When a request fails you get a non-2xx HTTP status and, usually, a body explaining why. There is no single error envelope: depending on where the request was rejected, the body is a JSON string, a JSON object, plain text, or empty. Build your client to read the response body as text on every failure and to tolerate all four.
Status codes
Section titled “Status codes”| Status | Meaning |
|---|---|
| 200, 201, 204 | Success. Creates usually return 201; updates return 200 or 204 depending on the resource, sometimes with no body. Treat any 2xx as success and don’t require a body. |
| 400 | Bad request. Validation failed, a business rule rejected the call, or a limit was exceeded (for example the patient-notes date window or paging ceiling). 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. A clinic id your keys are not linked to also returns 401, not 403. See “Common failure states” below. |
| 403 | Forbidden. The keys are valid, but the key’s permissions do not cover the requested endpoint or record. |
| 404 | Not found. The id does not exist within the clinic named by X-Clinic-Id, or the open-api- route does not exist. On client creates, a validation failure can also come back as 404. |
| 405 | Method not allowed. The wrong HTTP verb, or a path that does not start with /pav2/open-api-. |
| 409 | Conflict. The record is locked by a clinical workflow (for example a locked visit record), the patient is flagged for merge, or the value is a duplicate. |
| 429 | Too many requests. You exceeded a rate limit. |
| 500 | Server error. On this API a 500 is almost always caused by the request: an empty or null body, or a malformed UUID in X-Clinic-Id or a comma-separated id filter (such as ids or clientIds). Check those before anything else. |
| 501 | Not implemented. The endpoint does not support the sort you sent. Only two endpoints do this; most ignore an unsupported sort instead (see Sorting). |
| 502, 503 | Temporarily unavailable. A 503 whose body is "ERRORS.MAINTENANCE_MODE" means planned maintenance. |
Every authenticated request counts against your rate limit, including every rejected one in this table.
Error body shapes
Section titled “Error body shapes”Which shape 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, limits, authentication failures, and the API’s own 429) return the message as a JSON-encoded string: the body is a quoted string, not an object. Many messages are translation keys rather than sentences.
HTTP/1.1 400 Bad RequestContent-Type: application/json; charset=utf-8
"Difference of DateCreated filter 'From' and 'To' can't be more than 1 month. Refer to 'bulk-export' endpoint for requested resource."HTTP/1.1 400 Bad RequestContent-Type: application/json; charset=utf-8
"PATIENT.ERRORS.PATIENT_NOT_BELONG_TO_CLIENT"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 return an object. That covers a missing required field, a value that cannot be bound to its type, rpp or page below 1 or null, and a date window that ends before it starts. The message is always The request is invalid.; the other fields vary by case:
HTTP/1.1 400 Bad RequestContent-Type: application/json
{ "message": "The request is invalid.", "statusCode": 400, "details": null, "error": null, "modelState": null}3. Plain text
Section titled “3. Plain text”The rate-limiting layer in front of the API rejects excess traffic before it reaches the API, with the same message as the API’s own 429 but as plain text:
HTTP/1.1 429 Too Many RequestsContent-Type: text/plain
Request rate limit exceeded. Please wait before making further requests.4. An empty body
Section titled “4. An empty body”Some 400, 403, 404, and 409 responses carry no body at all. Fall back to the status code.
A safe reader handles all four: read the body as text; if it is empty, use the status; otherwise try to parse it as JSON, use the result if it is a string, read .message if it is an object, and fall back to the raw text if it is not JSON.
Handled exceptions
Section titled “Handled exceptions”The API distinguishes between two flavors of 400:
- Validation errors: the request shape is wrong or a required field is missing (shape 2), or a limit was exceeded, such as the patient-notes date window or paging ceiling (shape 1).
- Handled exceptions: the request was well-formed but a business rule rejected it (for example, booking an appointment into a provider’s break, or linking a task to a patient who does not belong to the task’s client). 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 send it again”; 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. Unless noted, the messages below arrive as bare JSON strings (shape 1 above). 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 call except open-api-clinics. 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, a key was revoked, the keys belong to the other environment (sandbox versus production), or the public key’s casing was changed. | Recheck the pair and the base URL, and send the public key in lowercase exactly as issued; 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, or your keys are not linked to that clinic at all: a mistyped clinic id, or one from the other environment, gets this same response. | Check the id against POST /pav2/open-api-clinics, which lists the clinics your keys can reach. If it is listed, the clinic enables your integration on their side; partners confirm this during onboarding. Skip that clinic until then. |
You don't have sufficient permissions to access the requested resource, please contact the support | 403 | The keys are valid, but the key’s permissions do not include this endpoint or record. | Do not retry. Contact your Shepherd account manager about the key’s access. |
Request must contain either DateCreated or DateUpdated filter parameters. Refer to 'bulk-export' endpoint for requested resource. | 400 | Patient notes were listed without a date window. | Send a dateCreatedFrom / dateCreatedTo or dateUpdatedFrom / dateUpdatedTo pair. |
DateCreated filter must define values for both 'From' and 'To'. Refer to 'bulk-export' endpoint for requested resource. (or DateUpdated) | 400 | Only one end of a patient-notes date pair was sent. | Send both ends. |
Difference of DateCreated filter 'From' and 'To' can't be more than 1 month. Refer to 'bulk-export' endpoint for requested resource. (or DateUpdated) | 400 | The patient-notes date window is wider than one calendar month. | Keep each window to one calendar month; sweep month by month for longer spans. |
Unable to read more than 10000 through this endpoint. Refer to 'bulk-export' endpoint for requested resource. | 400 | page * rpp exceeded the 10,000-item ceiling on patient notes. | Narrow the date window, then page again. See Conventions. |
ERROR_MESSAGES.SEARCH_PHRASE.TOO_LONG / ERROR_MESSAGES.SEARCH_PHRASE.TOO_MANY_PHRASES | 400 | searchQuery is longer than 500 characters or has more than 10 words. | Shorten the search. |
{"message":"The request is invalid."} | 400 | A required field was missing, a value in the body could not be bound to its type (malformed GUID, unparseable date), rpp or page was below 1 or null, or a date window ended before it started. 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. |
PRESCRIPTION.ERROR.SOAP_LOCKED / PATIENT.EDIT.WARNING_MERGE_PATIENT | 409 | The visit record is locked, or the patient is flagged for merge. | Do not retry; the clinic has to resolve it. |
Unhandled error occurred, please contact support team. | 500 | Almost always the request: an empty or null body, or a malformed UUID in X-Clinic-Id or an id filter. | Send {} as the minimum body and check every id. Do not retry; the same request fails the same way. |
Request rate limit exceeded. Please wait before making further requests. | 429 | You crossed a rate limit. From the API this is a JSON string; from the rate-limiting layer in front of it, the same text as plain text. | Wait until the start of the next UTC minute before sending anything else, then lower your steady-state rate. |
(no response, or 504) | The request took too long. | Do not send it again unchanged; make it cheaper (a shorter date window, a smaller rpp, fewer embeds). See “Retrying” below. |
Retrying
Section titled “Retrying”A simple, robust strategy:
- Do not retry:
400,401,403,404,405,409,500,501. These are caused by the request or its configuration, they count against your rate limit, and a retry just burns budget. A500belongs here too: on this API it is almost always caused by the request, so a retry fails the same way. - Wait, then retry:
429. Wait until the start of the next UTC minute (or the next UTC hour if your hourly budget is spent). See Rate limits. - Retry with exponential backoff and jitter:
502and503, for reads and updates, with a capped number of attempts. For a503maintenance response, back off for minutes, not seconds. - Do not retry timeouts. A request that timed out may still be running on the server, and sending it again starts the same work alongside it. Make the request cheaper instead.
- Never retry a create automatically (any
.../write), whatever the failure: the first attempt may have succeeded, and a retry makes a duplicate. Check whether the record exists first.
Reading the error body
Section titled “Reading the error body”The pattern below works for any endpoint. On failure, read the raw body as text first, since that works whichever shape came back, then pull out the message:
# -w prints the HTTP status on a final line, -s silences the progress bar,# --max-time sets a timeoutcurl -sS --max-time 30 -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,"dateUpdatedFrom":"2026-05-01T00:00:00Z","dateUpdatedTo":"2026-06-01T00:00:00Z"}' \ -w "\nHTTP %{http_code}\n"import json, os, requests
def error_message(resp): text = resp.text.strip() if not text: return f"HTTP {resp.status_code}" try: parsed = json.loads(text) except ValueError: return text # plain text, for example a 429 from the rate-limiting layer if isinstance(parsed, str): return parsed if isinstance(parsed, dict) and parsed.get("message"): return parsed["message"] return text
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, "dateUpdatedFrom": "2026-05-01T00:00:00Z", "dateUpdatedTo": "2026-06-01T00:00:00Z", }, timeout=30,)
if not resp.ok: raise RuntimeError(f"Shepherd {resp.status_code}: {error_message(resp)}")
data = resp.json()using System.Net.Http.Json;using System.Text.Json;
static string ErrorMessage(int status, string text){ if (string.IsNullOrWhiteSpace(text)) return $"HTTP {status}"; try { using var doc = JsonDocument.Parse(text); if (doc.RootElement.ValueKind == JsonValueKind.String) return doc.RootElement.GetString()!; if (doc.RootElement.ValueKind == JsonValueKind.Object && doc.RootElement.TryGetProperty("message", out var m) && m.ValueKind == JsonValueKind.String) return m.GetString()!; } catch (JsonException) { // plain text, for example a 429 from the rate-limiting layer } return text;}
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 = 1, rpp = 10, 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-clients", body);
var text = await resp.Content.ReadAsStringAsync();if (!resp.IsSuccessStatusCode){ throw new HttpRequestException( $"Shepherd {(int)resp.StatusCode}: {ErrorMessage((int)resp.StatusCode, text)}");}<?phpfunction shepherd_error_message(int $status, string $text): string{ if (trim($text) === '') { return "HTTP $status"; } $parsed = json_decode($text, true); if (json_last_error() !== JSON_ERROR_NONE) { return $text; // plain text, for example a 429 from the rate-limiting layer } if (is_string($parsed)) { return $parsed; } if (is_array($parsed) && !empty($parsed['message'])) { return $parsed['message']; } return $text;}
$ch = curl_init('https://open-api.shepherd.vet/pav2/open-api-clients');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' => 1, 'rpp' => 10, 'dateUpdatedFrom' => '2026-05-01T00:00:00Z', 'dateUpdatedTo' => '2026-06-01T00:00:00Z', ]),]);$response = curl_exec($ch);if ($response === false) { throw new RuntimeException('Shepherd request failed: ' . curl_error($ch));}$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);curl_close($ch);
if ($status < 200 || $status >= 300) { throw new RuntimeException("Shepherd $status: " . shepherd_error_message($status, $response));}$data = json_decode($response, true);function errorMessage(status, text) { if (!text.trim()) return `HTTP ${status}`; try { const parsed = JSON.parse(text); if (typeof parsed === 'string') return parsed; if (parsed && typeof parsed.message === 'string') return parsed.message; } catch { // plain text, for example a 429 from the rate-limiting layer } return text;}
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, dateUpdatedFrom: '2026-05-01T00:00:00Z', dateUpdatedTo: '2026-06-01T00:00:00Z', }), signal: AbortSignal.timeout(30_000),});
if (!resp.ok) { const text = await resp.text(); throw new Error(`Shepherd ${resp.status}: ${errorMessage(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 paging, date windows, and the patient-notes limits that produce a 400.
- Groups and multi-site for how clinic scoping works when data is shared across a group.