Rate limits
The Qaf API allows 60 requests a minute and 1,000 searches a day per account. The rate-limit headers, the 429 response, and how to back off.
Limits apply per Qaf account, across all of its API keys:
| Limit | Applies to |
|---|---|
| 60 requests a minute | Every endpoint. |
| 1,000 searches a day | POST /v1/search, counted over 24 hours. |
The MCP server has its own limit, also counted per account: see MCP server.
Headers
Once the API has accepted your key, its response reports the account’s minute window:
| Header | Value |
|---|---|
X-RateLimit-Limit |
Requests the account can make per minute. |
X-RateLimit-Remaining |
Requests left in the current minute. |
X-RateLimit-Reset |
Seconds until the minute’s allowance resets. |
These headers always describe the minute window. A 429 for the minute or day limit has them too; on a day refusal, Retry-After is the time until searches work again instead. They’re absent when the API refuses a request before it accepts the key: any 401 or 403, an invalid_keys 429, a body over 64 KiB (413), a body that isn’t valid JSON (400), or a path the API doesn’t have (404, 405).
When you reach a limit
The API answers 429 with a Retry-After header, in seconds, and the reason in the body:
{
"code": "RATE_LIMITED",
"status": 429,
"message": "Too many requests. Try again later.",
"data": { "retryAfter": 23, "reason": "minute" }
}
reason |
Means |
|---|---|
minute |
The account made 60 requests this minute. |
day |
The account ran 1,000 searches in the last 24 hours. The other endpoints still work. Retry-After is the time until searches work again, which can be many hours. |
invalid_keys |
The key you sent doesn’t exist or was revoked, and your network address has sent too many such keys. A key that works is never refused for this reason. |
Back off and retry
Retry a 429 only when reason is minute: wait Retry-After seconds, then send the request again. Don’t wait out the other reasons:
day: stop searching, and tell your users when searches work again.Retry-Aftercan be close to a day.invalid_keys: the key you send doesn’t exist or was revoked. Fix it: retrying with the same key fails again.
For 500 and 503 responses, which have no Retry-After, wait longer after each failed attempt.
const sleep = (seconds: number) =>
new Promise((resolve) => setTimeout(resolve, seconds * 1000));
async function callQaf(url: string, init: RequestInit, attempts = 4) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(url, init);
if (response.status === 429) {
const { data } = await response.clone().json();
if (data?.reason === "day")
throw new Error(`Daily search limit. Searches resume in ${data.retryAfter} s.`);
if (data?.reason === "invalid_keys")
throw new Error("Invalid API key. Check QAF_API_KEY.");
if (data?.reason !== "minute" || attempt === attempts) return response;
await sleep(Number(response.headers.get("Retry-After")));
} else if (response.status >= 500 && attempt < attempts) {
await sleep(2 ** attempt);
} else {
return response;
}
}
}import time
import requests
def call_qaf(method, url, attempts=4, **kwargs):
for attempt in range(1, attempts + 1):
response = requests.request(method, url, timeout=30, **kwargs)
if response.status_code == 429:
data = response.json().get("data") or {}
if data.get("reason") == "day":
raise RuntimeError(f"Daily search limit. Searches resume in {data['retryAfter']} s.")
if data.get("reason") == "invalid_keys":
raise RuntimeError("Invalid API key. Check QAF_API_KEY.")
if data.get("reason") != "minute" or attempt == attempts:
return response
time.sleep(int(response.headers["Retry-After"]))
elif response.status_code >= 500 and attempt < attempts:
time.sleep(2**attempt)
else:
return responseSpread requests over the minute rather than sending them in bursts, and cache results you read more than once: passages and verses rarely change.
Need more
If your project needs higher limits, email us at contact@qaf.ai with what you’re building and the volume you expect.