Rate limits
30 requests per minute per API key, the headers that report your window, and how to back off on 429.
Each API key can send up to 30 requests per minute, counted in fixed one-minute windows across every server of the API. Every request made with a valid key counts, reads and writes alike, including the ones answered 403, such as a plan that no longer includes the access. Every response past the key check reports the current window:
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed per window: 30. |
RateLimit-Remaining | Requests left in the current window. |
RateLimit-Reset | Seconds until the window resets. |
RateLimit-Limit: 30
RateLimit-Remaining: 17
RateLimit-Reset: 41Beyond the limit the API answers 429 Too Many Requests with a Retry-After header holding the seconds to wait.
{
"ok": false,
"timestamp": 1738247125207,
"message": "Rate limit of 30 requests per minute exceeded"
}Each key has its own window: one integration using up its limit does not slow down another key of the same organization.
Failed authentication
Requests that fail authentication (401) are throttled separately, per client address, before any key is looked up. After 30 failed requests in a minute, that address is answered 429 Too many failed authentication attempts until the minute ends, whatever key it sends. IPv6 addresses count by their /64.
When the limiter is unavailable
The API never serves a request it cannot count. If the rate-limit store is briefly unreachable, it answers 503 Rate limiter unavailable: retry with backoff.
Stay under the limit
- Use
limit=100on lists. It needs half the requests of the default page size. - Cache the catalogs.
/metrics,/prescription-types,/units,/musclesand the global part of/exerciseschange rarely. Load them once and look ids up locally. - Filter on the server. Use
userId,fromandtoinstead of downloading everything and filtering yourself. - Read
RateLimit-Remainingand slow down before it reaches zero, instead of waiting for a429. - Do not parallelize without a ceiling. A few concurrent requests are fine. Hundreds are not.
Back off on 429
Wait Retry-After seconds, then continue where you stopped. Do not retry immediately: each retry also counts against the current window and keeps answering 429 until it resets.
# curl has a built-in retry for 429, 500 and 503
curl --retry 5 --retry-all-errors \
"https://api-next.vitruve.fit/v1/athletes" \
-H "X-API-Key: YOUR_API_KEY"async function request(path, attempt = 0) {
const response = await fetch(`https://api-next.vitruve.fit/v1${path}`, {
headers: { 'X-API-Key': process.env.VITRUVE_API_KEY }
})
if (response.ok) return response.json()
const retryable = [429, 500, 503].includes(response.status)
if (!retryable || attempt >= 5) {
const body = await response.json()
throw new Error(`${response.status}: ${body.message} (request ${response.headers.get('x-request-id')})`)
}
const waitSeconds =
Number(response.headers.get('retry-after')) || Math.min(2 ** attempt, 30)
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000))
return request(path, attempt + 1)
}import os
import time
import requests
def request(path, **params):
for attempt in range(6):
response = requests.get(
f"https://api-next.vitruve.fit/v1{path}",
params=params,
headers={"X-API-Key": os.environ["VITRUVE_API_KEY"]},
timeout=30,
)
if response.ok:
return response.json()
if response.status_code not in (429, 500, 503) or attempt == 5:
request_id = response.headers.get("X-Request-Id")
raise RuntimeError(
f"{response.status_code}: {response.json()['message']} (request {request_id})"
)
wait = int(response.headers.get("Retry-After", min(2**attempt, 30)))
time.sleep(wait)