Errors & rate limits
Every status code a generated API returns, the error body shape, and how per-key and per-endpoint rate limits work (429 with Retry-After) - with retry examples.
Error body
Errors that have a body return an error message, plus the constraint, column or query
parameter involved when there is one:
{ "error": "Invalid API key." }
{ "error": "Column 'title' is required.", "column": "title" }Some responses have no body: 404 for a missing row or unknown route, and 405. Check the status code first, then read error if there is a body.
Status codes
| Status | When | Body |
|---|---|---|
200 |
Read succeeded | the row or array of rows |
201 |
Row created | {"id": <new key>} |
204 |
Row updated or deleted | empty |
400 |
Malformed request: body not valid JSON or not an object, unknown filter / sort / fields column or bad count (parameter), PATCH with an empty body or a column it cannot change, malformed composite key, PUT with nothing to update |
error |
401 |
Missing, unknown, revoked or expired credential; private OpenAPI document without a key | error |
403 |
IP not allowed, or role too low for the method | error |
404 |
No row with that key, no such route, or a disabled / deleted endpoint | empty |
405 |
Method not routed (e.g. POST to a row URL) |
empty, Allow header |
409 |
Duplicate key or unique value; DELETE of a row that is still referenced |
error, constraint |
413 |
Request body over 1 MB | error |
422 |
A value the column cannot take: wrong type, required column missing, reference to a missing row, check constraint, too long / out of range | error, column or constraint |
429 |
Caller quota, address ceiling or concurrent token / certificate check limit exceeded | error, Retry-After header |
503 |
The token issuer's signing keys could not be fetched, or credential configuration is too stale | error, Retry-After header |
500 |
Any other database or server error | error |
400 means the request itself is malformed; 409 and 422 mean it was well-formed but the data
conflicts with what is stored or with the column's rules. The full messages are listed on
Authentication and
Create, update & delete.
Rate limits
Every authenticated caller has a per-minute request quota on the API:
| Caller | Counted per | Quota set in |
|---|---|---|
| API key | key | the key, under Auth & Keys |
| JWT | token subject (sub); tokens without one, per client IP address |
Auth & Keys → JWT Bearer → Rate limit, default 60 |
| Client certificate (mTLS) | certificate (SHA-256 fingerprint) | Auth & Keys → Mutual TLS → Rate limit, default 60 |
Quotas are capped at your plan's per-key limit. An endpoint can also have its own per-minute limit (Endpoints page), counted per caller on that route, and on Public endpoints per client IP address. Anonymous requests to a Public endpoint also count toward the address ceiling described below.
Every request counts, including failed ones after authentication. Each response to a limited request says where the caller stands:
HTTP/1.1 200
Content-Type: application/json
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 1
X-RateLimit-Reset: 60When the quota is used up, the API answers 429 until the window resets:
HTTP/1.1 429
Content-Type: application/json; charset=utf-8
Retry-After: 60
X-RateLimit-Limit: 2
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 60
{"error":"Rate limit exceeded. Try again later."}- The window is a fixed one-minute window that starts with the caller's first request in it.
X-RateLimit-ResetandRetry-Afterare the seconds until it resets, rounded up. - When both the caller's quota and the endpoint's limit apply, both are counted and the first one
used up answers
429. The headers show the one closest to running out. - The counters are shared by all instances of your API. If that shared store is briefly unreachable, each instance counts on its own until it is back, so a caller can get more requests through during that time; requests are never rejected because of it.
Failed authentication and floods
Traffic that does not authenticate is counted per client address: an IPv4 address, or an
IPv6 /64, with larger allowances for the surrounding /56 and /48. Once the address group is
over this ceiling, JWTs and uncached client certificates receive 429 before signature or
certificate-chain validation, even if the credential is valid. They can be retried after the
window ends. API keys and cached certificate results bypass this validation gate; authenticated
requests still have their own caller and endpoint quotas.
| Limit | Counts | Default | Then |
|---|---|---|---|
| Unauthenticated requests | requests with no credential, a rejected credential, or anonymous calls to Public endpoints and public documentation | 600 per minute per address | 429 until the minute ends |
| Concurrent token / certificate checks | JWT signature checks and new client certificates being verified at the same time | 4 per IPv4 address or IPv6 /64; 16 per /56 and 64 per /48 | 429 with Retry-After: 1 |
HTTP/1.1 429
Content-Type: application/json; charset=utf-8
Retry-After: 41
{"error":"Too many unauthenticated requests from this address. Try again later."}- Shared addresses. Valid API keys, including new, rotated and idle keys, and cached
certificates keep working when another caller exceeds the address ceiling. JWTs and uncached
certificates wait until that window ends. If all validation slots are occupied, these
expensive credentials receive
429withRetry-After: 1until a slot is free. Without a trusted proxy, the address ceiling is disabled and one runtime-wide cap of 32 validations applies. - One credential per request. If a request carries several (an API key, a bearer token and a
client certificate), only the first is checked, in that order, among the methods the endpoint
accepts. The others are ignored, even when the first is rejected: a stale
X-API-Keynext to a valid bearer token is a401. Send one credential. - Changes take effect within 30 seconds. A new key works at once (or within 10 seconds); a
revoked key and changed endpoint, method or mTLS settings take effect within 30 seconds; a
published or unpublished documentation portal too. An expired key, token or certificate is
refused at its expiry. If configuration refreshes keep failing, credential checks and
documentation return
503after ten refresh intervals (5 minutes by default), until a full refresh succeeds. - A bearer token is only checked when its issuer is the configured one and, with a JWKS, its
kid, when present, is one of the issuer's current keys. A token withoutkidis checked against the issuer's current keys under the same validation limits. When the issuer's keys cannot be fetched, the answer is503withRetry-After, not401: retry later. 429from these limits has noX-RateLimit-*headers; retry afterRetry-After.
Retrying
Retry 429 after Retry-After, and network failures or 502/503/504 with backoff. Do
not retry other 4xx responses or a 500 from the database: they fail the same way every
time.
# curl retries 429 and 5xx, honouring Retry-After
curl -s --retry 3 --retry-max-time 200 \
"$RESTWARD_API_URL/api/public/artist?limit=3" -H "X-API-Key: $RESTWARD_API_KEY"async function getWithRetry(url, init, attempts = 3) {
for (let i = 1; ; i++) {
const res = await fetch(url, init);
if (res.status !== 429 || i === attempts) return res;
const seconds = Number(res.headers.get("Retry-After") ?? 60);
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
}
}import time
def get_with_retry(session, url, attempts=3, **kwargs):
for i in range(1, attempts + 1):
res = session.get(url, **kwargs)
if res.status_code != 429 or i == attempts:
return res
time.sleep(int(res.headers.get("Retry-After", 60)))async Task<HttpResponseMessage> GetWithRetryAsync(HttpClient http, string url, int attempts = 3)
{
for (var i = 1; ; i++)
{
var res = await http.GetAsync(url);
if (res.StatusCode != HttpStatusCode.TooManyRequests || i == attempts) return res;
await Task.Delay(res.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(60));
}
}Handling errors
status=$(curl -s -o body.json -w '%{http_code}' \
"$RESTWARD_API_URL/api/public/artist/abc" -H "X-API-Key: $RESTWARD_API_KEY")
[ "$status" -ge 400 ] && echo "HTTP $status: $(cat body.json)"
# HTTP 422: {"error":"Value 'abc' is not valid for column 'artist_id' of type 'integer'.","column":"artist_id"}const res = await fetch(`${process.env.RESTWARD_API_URL}/api/public/artist/abc`, { headers });
if (!res.ok) {
const text = await res.text();
const message = text ? JSON.parse(text).error : res.statusText;
throw new Error(`HTTP ${res.status}: ${message}`);
}res = session.get(f"{api}/api/public/artist/abc")
if not res.ok:
message = res.json().get("error") if res.content else res.reason
raise RuntimeError(f"HTTP {res.status_code}: {message}")var res = await http.GetAsync("/api/public/artist/abc");
if (!res.IsSuccessStatusCode)
{
var text = await res.Content.ReadAsStringAsync();
var message = text.Length > 0 ? JsonDocument.Parse(text).RootElement.GetProperty("error").GetString() : res.ReasonPhrase;
throw new HttpRequestException($"HTTP {(int)res.StatusCode}: {message}");
}