Rate limits
Rate limits apply per integration key, not per clinic: a key used for 40 clinics shares one budget across all 40.
| Window | Default limit |
|---|---|
| 1 minute | 800 requests |
| 1 hour | 12,000 requests |
Both apply simultaneously: you can use up to 800 requests in a minute, but you cannot sustain that past the hourly cap (12,000 per hour is 200 per minute sustained). These are the defaults; the limits for your integration can differ, so read them from the usage endpoint rather than hard-coding them.
How requests are counted
Section titled “How requests are counted”- Fixed UTC windows. The minute counter resets at the start of each UTC minute and the hour counter at the start of each UTC hour. They are not sliding windows.
- Every authenticated request counts, including rejected ones. A
401 Missing X-Clinic-Id header, a400, a429, and a500all use quota; only the usage endpoint below is free. A loop that resends a failing request burns the budget and keeps you locked out. - There is a second limit in front of the API. It also limits each key, over windows of a few minutes, independently of the API’s own counters. Traffic sustained near the per-minute limit, or an integration with a raised API limit, can still be rejected there. Spread requests out instead of firing them all at the top of the minute.
What 429 looks like
Section titled “What 429 looks like”When the API rejects a request for exceeding a limit, the body is a JSON-encoded string:
HTTP/1.1 429 Too Many RequestsContent-Type: application/json; charset=utf-8
"Request rate limit exceeded. Please wait before making further requests."When the limit in front of the API rejects it, you get the same message as plain text:
HTTP/1.1 429 Too Many RequestsContent-Type: text/plain
Request rate limit exceeded. Please wait before making further requests.Handle both: read the body as text, and parse it as JSON only if it parses. See Errors.
No Retry-After or rate-limit headers are sent. After a 429, wait until the start of the next UTC minute before sending anything else; if the hourly budget is spent, wait until the next UTC hour.
Querying current usage
Section titled “Querying current usage”You can ask the API where you stand at any time:
POST /pav2/open-api-rate-limitsSend the usual three headers, including X-Clinic-Id, and an empty JSON object. The call does not count against your budget. The response carries your integration’s actual limits and what is left in the current windows:
{ "remainingRequestsThisHour": 11650, "remainingRequestsThisMinute": 790, "requestsPerHour": 12000, "requestsPerMinute": 800}Call it at the start of a batch job to size the run, and after a 429 to decide whether to wait for the next minute or the next hour.
curl -X POST https://open-api.shepherd.vet/pav2/open-api-rate-limits \ -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 '{}'import os, requests
resp = requests.post( "https://open-api.shepherd.vet/pav2/open-api-rate-limits", 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={}, timeout=30,)resp.raise_for_status()print(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 { });var resp = await http.PostAsync( "https://open-api.shepherd.vet/pav2/open-api-rate-limits", body);resp.EnsureSuccessStatusCode();Console.WriteLine(await resp.Content.ReadAsStringAsync());<?php$ch = curl_init('https://open-api.shepherd.vet/pav2/open-api-rate-limits');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((object)[]),]);$response = curl_exec($ch);curl_close($ch);echo $response;const resp = await fetch('https://open-api.shepherd.vet/pav2/open-api-rate-limits', { 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({}),});if (!resp.ok) throw new Error(`Shepherd ${resp.status}: ${await resp.text()}`);console.log(await resp.json());Retries and backoff
Section titled “Retries and backoff”| Outcome | Retry? | How |
|---|---|---|
429 | Yes | Wait until the next UTC minute (next UTC hour if the hourly budget is spent), plus a little random jitter. |
502, 503 | Yes, for reads and updates | Exponential backoff with jitter: min(60s, 1s * 2^(attempt-1)) plus up to 1 second, giving up after 4 attempts. A 503 maintenance response means back off for minutes. |
Timeout, no response, 504 | No | Make the request cheaper (shorter date window, smaller rpp, fewer embeds) or stop. |
400, 401, 403, 404, 409, 501 | No | Fix the request or configuration. |
500 | No | On this API a 500 is almost always caused by the request (an empty or null body, a malformed id), so a retry fails the same way. Fix the request. |
Any failure on a create (.../write) | No automatic retry | Check whether the record was created before trying again. |
Why timeouts are not retried: a request that timed out on your side may still be running on the server. Sending it again starts the same work again alongside the first copy, and an integration that retries timeouts in a loop turns one slow request into many.
Add a circuit breaker on top: after a few consecutive timeouts or 5xx/429 responses, stop calling the API for about a minute, then allow a single trial request.
Request shape matters more than request count
Section titled “Request shape matters more than request count”The rate limit caps how many requests you send, not how much work each one causes. A request for a whole clinic’s history, a deep embed over a large page, or a filter the API ignores (misspelled, or one the endpoint does not apply) can cost orders of magnitude more than a normal request while counting as one.
- Bound every list. Use a date window of one calendar month or less, or an id filter, on clinical and financial data.
- Use moderate page sizes. 100 to 500 records per page without embeds; 250 or less with embeds; 100 or less on physical exams and SOAP discharge instructions, whose records are large. See Conventions.
- Cache lookups. Reference data (species, breeds, phone types) changes rarely; cache it instead of re-embedding on every request.
- Sync incrementally. Poll on
dateUpdatedFrom/dateUpdatedToevery 5 to 15 minutes, not every few seconds. - Share the budget across workers. Every process using a key draws on the same limit. Default to sequential requests; if you need concurrency, keep it small and fixed (4 at most) behind one shared limiter.
- Schedule batch jobs off-peak. Bulk syncs overnight in the clinic’s time zone are unlikely to collide with interactive traffic.
- Resume, don’t restart. If a backfill fails halfway through, resume from the last completed page or month.
Need a higher limit
Section titled “Need a higher limit”If your integration legitimately needs more headroom, talk to your Shepherd account manager. Larger limits are negotiated per integration rather than self-service.