Rate limits
Per-account, per-tier limits with full header guidance and back-off algorithms.
Rate limiting runs in two layers:
- Authenticated requests: per-account sliding-window enforcer
- Unauthenticated requests: IP-level tarpit
Bursting above the hard limit returns 429 Too Many Requests with code: rate_limit_exceeded.
Authenticated limits (per account)
Limits are applied per account per 60-second sliding window and vary by endpoint tier.
| Tier | Endpoints | Live keys | Test keys |
|---|---|---|---|
| Heavy | Prequalify, Quote | 12,000 req/min | 1,000 req/min |
| Light | All other endpoints (datasets, tokens, accounts, cases, licensing, usage) | 120,000 req/min | 1,000 req/min |
Test-mode quotas are intentionally lower than live. This prevents noisy CI loops from masking integration bugs. A real test-mode call returns x-ratelimit-limit: 1000 in the response headers.
The window is a sliding 60 seconds, not a fixed clock boundary. A burst of 12,000 heavy requests at 10:00:00 exhausts the window until 10:01:00. Requests after that point draw from a fresh window.
Limits apply per account. Multiple integrations under separate accounts each get the full quota.
For higher limits on a single account, email [email protected] with a sustained-traffic estimate and a representative request_id.
Tarpit zone
Between 80% and the hard limit, the server adds a progressive delay: 200ms per request over the soft limit, capped at 5 seconds.
Requests are not rejected in this zone — they just slow down. Use the x-ratelimit-remaining header to self-throttle before entering the tarpit.
Unauthenticated traffic
Unauthenticated requests (no bearer credential in Authorization header) are rate-limited by client IP: 60 req/min soft limit, 120 req/min hard limit.
Authenticated requests are never subject to IP-based limiting.
Health probes (/livez, /readyz) and logo assets (/v1/logo/,
/v1/logos/) are unconditionally exempt from rate limiting.
Response headers
Every authenticated response carries the current window state for the relevant tier.
Header names are emitted in lowercase per HTTP/2 convention. When grepping logs, match on lowercase:
| Header | Type | Description |
|---|---|---|
x-ratelimit-limit | integer | Maximum requests allowed in the current 60-second window. |
x-ratelimit-remaining | integer | Requests remaining before the hard limit. |
x-ratelimit-reset | integer (Unix seconds) | When the current window resets. |
x-ratelimit-limit: 12000
x-ratelimit-remaining: 11997
x-ratelimit-reset: 1748016060x-ratelimit-reset is a Unix timestamp in seconds. Treat all three headers as advisory for self-throttling. The authoritative signal is a 429 response.
Unauthenticated (IP-limited) requests do not carry x-ratelimit-* headers.
The 429 response
When the hard limit is exceeded the server returns:
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 60{
"code": "rate_limit_exceeded",
"title": "Rate limit exceeded",
"status": 429,
"detail": "Too many requests. Retry after 60 seconds.",
"instance": "/v2/prequalify",
"request_id": "req_01HZK2N5GQR9T8X4B6FJW3Y1AS",
"type": "https://zyins.isaapi.com/errors/rate-limit-exceeded"
}Retry-After is always 60 (seconds). The body does not include a
retry_after_ms field — use the header value.
Sustained exhaustion
The rate limiter has no persistent penalty state. Once the sliding window drains below the hard limit, the account receives requests normally again.
There is no escalating block, cool-down multiplier, or account suspension from rate-limit events alone. Sustained exhaustion simply means every request above the limit returns 429 until the window rolls forward.
Recommended back-off algorithm
Use exponential back-off with full jitter and a cap. The algorithm:
attempt = 0
cap = 32s
base = 1s
wait(attempt):
floor = min(cap, base * 2 ** attempt)
sleep(random(0, floor))
attempt += 1
On a 429, read Retry-After first. If present, use it as the minimum wait before applying jitter.
Never tight-loop. Each 429 keeps the window full.
TypeScript
import { IsaRateLimitError, IsaApiError } from 'isa-sdk';
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts = 5,
): Promise<T> {
const BASE_MS = 1_000;
const CAP_MS = 32_000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err: unknown) {
const isRateLimit = err instanceof IsaRateLimitError;
const isRetryable =
isRateLimit ||
(err instanceof IsaApiError &&
[502, 503, 504].includes(err.status));
if (!isRetryable || attempt === maxAttempts - 1) throw err;
// Honour Retry-After when present, otherwise exponential + jitter
const retryAfterMs =
isRateLimit && err instanceof IsaRateLimitError && err.retryAfterSeconds !== undefined
? err.retryAfterSeconds * 1_000
: 0;
const cap = Math.min(CAP_MS, BASE_MS * 2 ** attempt);
const jitter = Math.random() * cap;
await sleep(Math.max(retryAfterMs, jitter));
}
}
throw new Error('unreachable');
}
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));PHP
function withRetry(callable $fn, int $maxAttempts = 5): mixed
{
$baseMs = 1_000;
$capMs = 32_000;
for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
try {
return $fn();
} catch (\Throwable $e) {
$code = method_exists($e, 'getCode') ? $e->getCode() : 0;
$status = method_exists($e, 'getHttpStatus') ? $e->getHttpStatus() : 0;
$isRateLimit = ($code === 'rate_limit_exceeded' || $status === 429);
$isRetryable = $isRateLimit || in_array($status, [502, 503, 504], true);
if (!$isRetryable || $attempt === $maxAttempts - 1) {
throw $e;
}
// Honour Retry-After when present
$retryAfterMs = ($isRateLimit && method_exists($e, 'retryAfterSeconds'))
? $e->retryAfterSeconds() * 1_000
: 0;
$cap = min($capMs, $baseMs * (2 ** $attempt));
$jitter = random_int(0, $cap);
$waitMs = max($retryAfterMs, $jitter);
usleep($waitMs * 1_000); // usleep takes microseconds
}
}
throw new \LogicException('unreachable');
}Retry on: 429, 502, 503, 504. Do not retry on other 4xx codes —
they indicate a request error the caller must fix.
Updated about 10 hours ago