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.

TierEndpointsLive keysTest keys
HeavyPrequalify, Quote12,000 req/min1,000 req/min
LightAll other endpoints (datasets, tokens, accounts, cases, licensing, usage)120,000 req/min1,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:

HeaderTypeDescription
x-ratelimit-limitintegerMaximum requests allowed in the current 60-second window.
x-ratelimit-remainingintegerRequests remaining before the hard limit.
x-ratelimit-resetinteger (Unix seconds)When the current window resets.
x-ratelimit-limit: 12000
x-ratelimit-remaining: 11997
x-ratelimit-reset: 1748016060

x-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.