Documentation / Guides
Errors
Every code, what causes it, and what to do about it.
Every failure returns JSON with an error object and no data. Branch
on error.code, never on the message: codes are part of the contract, messages are
wording and may be improved.
{
"error": {
"code": "outside_window",
"message": "That date is outside the 24h – 7d your plan reads.",
"doc_url": "https://apihorseracing.com/documentation/errors#outside_window",
"request_id": "17d0af45d676676d",
"your_plan": "free",
"window": "24h – 7d",
"requested": "2021-02-28"
}
}
The one distinction that matters
Before the table: most of these are not transient. A client that retries everything on a backoff will burn its own quota against a wall. Only 429 and 500 are worth retrying at all.
Every code
| Code | HTTP | Cause | Do |
|---|---|---|---|
unauthorized | 401 | No key, or one we do not recognise. | Check the header name is X-API-Key and that the key has not been
rotated from your account page. Never retry. |
upgrade_required | 403 | Valid key, but the plan does not reach this endpoint. Always a statistics endpoint. | The body carries your_plan and reaches_on. Show the user
which plan would work. Never retry. |
outside_window | 403 | The date is outside what your plan reads. | The body names window and requested. Clamp your own
request or upgrade. Never retry. |
forbidden | 403 | The account is disabled or the key revoked. | Contact support. This is never a rate limit. |
not_found | 404 | The identifier is well formed but nothing has it, or a race exists with no result yet. | Treat as an answer, not a failure. Never retry. |
invalid_id | 422 | The identifier is malformed, or its prefix is wrong for this path. | You have probably passed a horse id where a race is wanted. See Identifiers. |
invalid_param | 422 | A parameter failed validation. The message names it. | A date that is not YYYY-MM-DD, a by= dimension we do not have, or a
cursor you constructed. |
rate_limited | 429 | Past your per-minute rate. | Retry after Retry-After seconds. Slow down rather
than parallelising harder. |
quota_exceeded | 429 | Past your monthly allowance. | Resets on the first. Retrying before then is pointless. Check
/v1/account/usage. |
not_yet_live | 503 | Registered and documented, but not serving yet. | Watch status. |
server_error | 500 | Ours, not yours. | Retry once with a backoff. If it persists, send us the
request_id. |
Handling it properly
switch ($err['code'] ?? '') {
case 'rate_limited':
sleep((int) ($headers['retry-after'] ?? 5));
// retry the same request
break;
case 'server_error':
// retry once, then give up and log request_id
break;
case 'outside_window':
case 'upgrade_required':
// a plan decision, not a failure. Surface it, do not retry.
break;
case 'not_found':
// an answer. Store the absence.
break;
default:
// your bug. Log it with request_id and stop.
}
The three headers worth reading
X-RateLimit-Remainingon every successful response. Slow down before you hit zero rather than after.Retry-Afteron a 429. Respect it rather than backing off blindly.X-Request-Idon everything. Log it. It costs nothing and it is the difference between us finding your problem in a minute and not at all.
What is never an error
An empty array. If we looked and there was nothing, that is a 200 with
data: []: no racing that day, no runs in your window, no dividends published.
Treating an empty result as a failure and retrying it is the most common mistake we see.