Rate Limits
Every API has two throughput limits, applied per account and per API service rather than per API key: a concurrency limit (how many of your requests can be active at the same time) and a requests-per-second limit (how many you may start per window). Exceeding either returns HTTP 429. These limits are about throughput, not usage.
The two limits are separate budgets. On most APIs the requests-per-second limit is set to the same value as the concurrency limit, but that is not a guarantee: they may differ per API, and we may change one without the other. Read each one from its own response header rather than deriving one from the other.
Rate limit headers
Every response shows both API limits and your remaining allowance:
X-Concurrency-Limit: 10
X-Concurrency-Remaining: 9
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Window: 1
X-RateLimit-Reset-After: 1The X-Concurrency-* pair limits in-flight requests; the X-RateLimit-* family describes how many you may start per window. Size your worker pool from the first and pacing from the second.
| Header | Meaning |
|---|---|
X-Concurrency-Limit | Maximum number of concurrent requests (active connections) your plan allows on this API. |
X-Concurrency-Remaining | How many additional concurrent requests you can open on this API right now. |
X-RateLimit-Limit | Maximum number of requests you may start per window on this API. |
X-RateLimit-Remaining | How many requests are left in your current window. |
X-RateLimit-Window | Window length in seconds (effective rate = Limit ÷ Window). |
X-RateLimit-Reset-After | Seconds until a request slot frees up. |
X-RateLimit-Scope | Only on 429 responses: which limit you hit, either requests or concurrency. |
Retry-After | Only on 429 responses: seconds to wait before retrying. |
Regarding X-RateLimit-Reset-After: the rate limit uses a sliding window, so there is no fixed moment when the counter drops to zero. The value counts down to when the oldest request in your window ages out and one slot becomes available, so on an API with a one-second window it stays at 1 at full rate rather than counting toward a boundary. A simple rule that works on every API: when X-RateLimit-Remaining is 0, wait X-RateLimit-Reset-After seconds before your next request, and you will not hit the rate limit. On APIs with a longer window this matters most: Reset-After is usually shorter than the full X-RateLimit-Window, so sleeping for the whole window would waste time.
Regarding Retry-After: it is always a floor, the minimum you should wait, not a promise that the retry will then succeed. How tight the Retry-After floor is depends on which limit you hit. For the requests-per-second limit it is tight: waiting the stated number of seconds frees one slot, so a single retry normally goes through. For the concurrency limit it is loose (always 1): a slot frees only when an in-flight request completes, which on slower APIs can take considerably longer, up to that API's request timeout. Branch on X-RateLimit-Scope to tell the two apart.
When you are rate limited
If you exceed the concurrency (active connections) limit or start more requests per window than X-RateLimit-Limit, the API responds with status 429, an error body, an X-RateLimit-Scope header naming the limit you hit (can be concurrency or requests), and a Retry-After header, to indicate temporary rate limiting:
HTTP/2 429
...
X-RateLimit-Scope: concurrency
Retry-After: 1429 is a safe status code to retry automatically, and no credits are consumed. This means the request was rejected before normal processing, so retry behavior can be handled predictably without risking duplicate billable work.
Here are the two cases:
X-RateLimit-Scope: requests– requests-per-second limit.Retry-Afteris a tight floor: wait that many seconds and one slot frees, so the retry should go through. The retry examples wait for the larger ofRetry-Afterand their backoff step, so they satisfy the header on any window length and avoid an immediate retry that would fail.X-RateLimit-Scope: concurrency– concurrency limit.Retry-Afteris a loose floor (always1): a slot only frees when an in-flight request completes, and on slower APIs that can take well over a second. Don't retry in a tight loop based on the header; use the incremental backoff retries, whose growing delays give in-flight requests time to finish. Or better, avoid most of these429errors by never opening more connections thanX-Concurrency-Limit, as shown below. Capping your own pool is enough when you are the only caller; if several processes share the account, they draw on the same budget, so make sure to leave a little headroom in each.
Add your own jitter. Retry-After is a floor, not an exact estimate, and every client rejected in the same instant receives the same Retry-After value. If several of your workers are throttled together, spread their retries by adding a small random delay on top of the header value, otherwise they will all come back simultaneously and collide again.
Limits per plan
Each subscription plan allows a different number of concurrent connections, and the exact value also varies per API service; heavier APIs have lower limits than lighter ones by design. Typical ranges:
| Plan | Concurrent requests per API |
|---|---|
| Basic | 5 – 10 |
| Startup | 20 – 25 |
| Growth | 30 – 40 |
| Business | 45 – 50 |
| Enterprise | 50 – 75 |
Read the limits from the headers, not from this table. The exact value depends on which API service you are calling, and limits are adjusted as our capacity grows. Rather than hardcoding a number, read X-Concurrency-Limit and X-RateLimit-Limit from any response, as shown below, and your client always uses the current limit.
Size your client from the headers
When processing a large batch, e.g. scanning 1,000 IP addresses, don't guess how many parallel requests to open or how fast to send them. Make a single request first and read both limits from the response: size your worker pool from X-Concurrency-Limit, and pace your requests at X-RateLimit-Limit ÷ X-RateLimit-Window per second. You get your plan's full throughput, and automatically adapt to any limit change without modifying your code:
import json
import time
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
ENDPOINT = "https://api.apivoid.com/v2/ip-reputation"
API_KEY = "YOUR_API_KEY_HERE"
class RateLimiter:
def __init__(self, rate_per_sec):
self.interval = 1.0 / rate_per_sec
self.next_slot = time.monotonic()
self.lock = Lock()
def acquire(self):
with self.lock:
now = time.monotonic()
wait = max(0.0, self.next_slot - now)
self.next_slot = max(now, self.next_slot) + self.interval
if wait:
time.sleep(wait)
def call_apivoid(ip: str):
request = urllib.request.Request(
ENDPOINT,
data=json.dumps({"ip": ip}).encode("utf-8"),
headers={"Content-Type": "application/json", "X-API-Key": API_KEY},
method="POST",
)
with urllib.request.urlopen(request, timeout=180) as response:
return json.loads(response.read().decode("utf-8")), response.headers
ips = ["80.82.77.139", "1.1.1.1", "8.8.8.8"] # ... e.g. 1,000 IPs to scan
# 1. Single request first: discover the current limits
data, headers = call_apivoid(ips[0])
results = {ips[0]: data}
# 2. Pool size comes from the concurrency limit
max_workers = int(headers.get("X-Concurrency-Limit", 1)) # Default to 1 if not present
# 3. Pacing comes from the requests-per-second limit: Limit / Window
rps_limit = int(headers.get("X-RateLimit-Limit", max_workers))
rps_window = max(1, int(headers.get("X-RateLimit-Window", 1)))
limiter = RateLimiter(rps_limit / rps_window)
def call_apivoid_limited(ip: str):
"""Returns the API response, or None if the request failed."""
limiter.acquire()
try:
return call_apivoid(ip)[0]
except Exception as error: # HTTP error (e.g. 429), connection error or timeout
print(f"{ip} failed: {error}")
return None
# 4. Process the rest with a worker pool sized to the concurrency limit.
# A failed IP is reported and skipped, so one error never stops the batch.
with ThreadPoolExecutor(max_workers=max_workers) as pool:
for ip, result in zip(ips[1:], pool.map(call_apivoid_limited, ips[1:])):
if result is not None:
results[ip] = result
failed = len(ips) - len(results)
print(f"Scanned {len(results)} IPs with {max_workers} workers, {failed} failed")$endpoint = 'https://api.apivoid.com/v2/ip-reputation';
$apiKey = 'YOUR_API_KEY_HERE';
function newHandle(string $endpoint, string $apiKey, string $ip)
{
$curl = curl_init($endpoint);
curl_setopt_array($curl, [
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'X-API-Key: ' . $apiKey],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['ip' => $ip]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true, // Keep the response headers, the limits are read from them
CURLOPT_TIMEOUT => 180, // Client-side timeout, so a stalled request frees its slot
CURLOPT_PRIVATE => $ip, // Remember which IP this handle belongs to
]);
return $curl;
}
// Split a raw response into [HTTP status code, headers (lowercase names), body]
function parseResponse($curl, string $raw): array
{
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
$headers = [];
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
if (strpos($line, ':') !== false) {
[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}
}
return [$httpCode, $headers, substr($raw, $headerSize)];
}
$ips = ['80.82.77.139', '1.1.1.1', '8.8.8.8']; // ... e.g. 1,000 IPs to scan
$results = [];
// 1. Single request first: discover the current limits
$curl = newHandle($endpoint, $apiKey, $ips[0]);
$raw = curl_exec($curl);
[$httpCode, $headers, $body] = parseResponse($curl, (string) $raw);
curl_close($curl);
if ($httpCode !== 200) {
throw new RuntimeException('First request failed with HTTP ' . $httpCode);
}
$results[$ips[0]] = json_decode($body, true);
// 2. Pool size comes from the concurrency limit
$maxWorkers = max(1, (int) ($headers['x-concurrency-limit'] ?? 1)); // Default to 1 if not present
// 3. Pacing comes from the requests-per-second limit: Limit / Window
$rpsLimit = max(1, (int) ($headers['x-ratelimit-limit'] ?? $maxWorkers));
$rpsWindow = max(1, (int) ($headers['x-ratelimit-window'] ?? 1));
$interval = $rpsWindow / $rpsLimit; // Seconds between two request starts
$nextSlot = microtime(true);
// 4. Process the rest with a curl_multi pool sized to the concurrency limit.
// A failed IP is reported and skipped, so one error never stops the batch.
$queue = array_slice($ips, 1);
$multi = curl_multi_init();
$inFlight = 0;
$failed = 0;
while ($queue || $inFlight > 0) {
// Start a request when a slot is free and the next paced start time has arrived
if ($queue && $inFlight < $maxWorkers && microtime(true) >= $nextSlot) {
$nextSlot = max(microtime(true), $nextSlot) + $interval;
curl_multi_add_handle($multi, newHandle($endpoint, $apiKey, array_shift($queue)));
$inFlight++;
continue; // Try to fill the next free slot right away
}
// Drive the transfers and collect the finished ones
curl_multi_exec($multi, $running);
while ($info = curl_multi_info_read($multi)) {
$curl = $info['handle'];
$ip = curl_getinfo($curl, CURLINFO_PRIVATE);
[$httpCode, $hdrs, $body] = parseResponse($curl, (string) curl_multi_getcontent($curl));
if ($info['result'] === CURLE_OK && $httpCode === 200) {
$results[$ip] = json_decode($body, true);
} else { // HTTP error (e.g. 429), connection error or timeout
$failed++;
echo $ip . ' failed: ' . ($httpCode ? 'HTTP ' . $httpCode : curl_strerror($info['result'])) . PHP_EOL;
}
curl_multi_remove_handle($multi, $curl);
curl_close($curl);
$inFlight--;
}
if ($inFlight > 0) {
curl_multi_select($multi, 0.05); // Wait for network activity, up to 50 ms
} else {
usleep(10000); // Nothing in flight: wait for the next paced start
}
}
curl_multi_close($multi);
printf("Scanned %d IPs with %d workers, %d failed\n", count($results), $maxWorkers, $failed);// Requires Node.js 18+ (built-in fetch) and must run as an ES module
// (a .mjs file or "type": "module" in package.json) because it uses top-level await
const ENDPOINT = "https://api.apivoid.com/v2/ip-reputation";
const API_KEY = "YOUR_API_KEY_HERE";
function createRateLimiter(ratePerSec) {
const interval = 1000 / ratePerSec;
let nextSlot = Date.now();
return async () => {
const now = Date.now();
const wait = Math.max(0, nextSlot - now);
nextSlot = Math.max(now, nextSlot) + interval;
if (wait) await new Promise((r) => setTimeout(r, wait));
};
}
async function callApiVoid(ip) {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": API_KEY },
body: JSON.stringify({ ip }),
signal: AbortSignal.timeout(180000), // Client-side timeout, so a stalled request frees its worker
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return { data: await response.json(), headers: response.headers };
}
const ips = ["80.82.77.139", "1.1.1.1", "8.8.8.8"]; // ... e.g. 1,000 IPs to scan
const results = {};
// 1. Single request first: discover the current limits
const first = await callApiVoid(ips[0]);
results[ips[0]] = first.data;
// 2. Pool size comes from the concurrency limit
const maxWorkers = parseInt(first.headers.get("x-concurrency-limit") ?? "1", 10); // Default to 1 if not present
// 3. Pacing comes from the requests-per-second limit: Limit / Window
const rpsLimit = parseInt(first.headers.get("x-ratelimit-limit") ?? String(maxWorkers), 10);
const rpsWindow = parseInt(first.headers.get("x-ratelimit-window") ?? "1", 10) || 1;
const acquire = createRateLimiter(rpsLimit / rpsWindow);
// 4. Process the rest with a worker pool sized to the concurrency limit.
// A failed IP is reported and skipped, so one error never stops the batch.
const queue = ips.slice(1);
let failed = 0;
await Promise.all(
Array.from({ length: maxWorkers }, async () => {
let ip;
while ((ip = queue.shift()) !== undefined) {
await acquire();
try {
results[ip] = (await callApiVoid(ip)).data;
} catch (error) { // HTTP error (e.g. 429), connection error or timeout
failed++;
console.error(`${ip} failed: ${error.message}`);
}
}
})
);
console.log(`Scanned ${Object.keys(results).length} IPs with ${maxWorkers} workers, ${failed} failed`);package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
)
const endpoint = "https://api.apivoid.com/v2/ip-reputation"
const apiKey = "YOUR_API_KEY_HERE"
// Client-side timeout, so a stalled request frees its worker
var client = &http.Client{Timeout: 180 * time.Second}
func callApiVoid(ip string) (map[string]any, http.Header, error) {
payload, _ := json.Marshal(map[string]string{"ip": ip})
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", apiKey)
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, resp.Header, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var data map[string]any
err = json.NewDecoder(resp.Body).Decode(&data)
return data, resp.Header, err
}
func main() {
ips := []string{"80.82.77.139", "1.1.1.1", "8.8.8.8"} // ... e.g. 1,000 IPs to scan
results := map[string]map[string]any{}
// 1. Single request first: discover the current limits
data, headers, err := callApiVoid(ips[0])
if err != nil {
panic(err)
}
results[ips[0]] = data
// 2. Pool size comes from the concurrency limit
maxWorkers := 1 // Default to 1 if not present
if n, err := strconv.Atoi(headers.Get("X-Concurrency-Limit")); err == nil && n > 0 {
maxWorkers = n
}
// 3. Pacing comes from the requests-per-second limit: Limit / Window
rpsLimit := maxWorkers
if n, err := strconv.Atoi(headers.Get("X-RateLimit-Limit")); err == nil && n > 0 {
rpsLimit = n
}
rpsWindow := 1
if n, err := strconv.Atoi(headers.Get("X-RateLimit-Window")); err == nil && n > 0 {
rpsWindow = n
}
limiter := time.NewTicker(time.Duration(rpsWindow) * time.Second / time.Duration(rpsLimit))
defer limiter.Stop()
// 4. Process the rest with a semaphore sized to the concurrency limit.
// A failed IP is reported and skipped, so one error never stops the batch.
var mu sync.Mutex
var wg sync.WaitGroup
failed := 0
sem := make(chan struct{}, maxWorkers)
for _, ip := range ips[1:] {
wg.Add(1)
go func(ip string) {
defer wg.Done()
sem <- struct{}{} // concurrency limit first: take a slot
defer func() { <-sem }()
<-limiter.C // then pace: one token every rpsWindow/rpsLimit second
d, _, err := callApiVoid(ip)
mu.Lock()
defer mu.Unlock()
if err != nil { // HTTP error (e.g. 429), connection error or timeout
failed++
fmt.Printf("%s failed: %v\n", ip, err)
return
}
results[ip] = d
}(ip)
}
wg.Wait()
fmt.Printf("Scanned %d IPs with %d workers, %d failed\n", len(results), maxWorkers, failed)
}import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static final String ENDPOINT = "https://api.apivoid.com/v2/ip-reputation";
static final String API_KEY = "YOUR_API_KEY_HERE";
static final HttpClient CLIENT = HttpClient.newHttpClient();
static final Object rateLock = new Object();
static long intervalNanos;
static long nextSlot = System.nanoTime();
static void acquireRateLimit() throws InterruptedException {
long wait;
synchronized (rateLock) {
long now = System.nanoTime();
wait = Math.max(0, nextSlot - now);
nextSlot = Math.max(now, nextSlot) + intervalNanos;
}
if (wait > 0) Thread.sleep(wait / 1_000_000, (int) (wait % 1_000_000));
}
static HttpResponse<String> callApiVoid(String ip) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(ENDPOINT))
.header("Content-Type", "application/json")
.header("X-API-Key", API_KEY)
.timeout(Duration.ofSeconds(180)) // Client-side timeout, so a stalled request frees its worker
.POST(HttpRequest.BodyPublishers.ofString("{\"ip\": \"" + ip + "\"}"))
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) throw new RuntimeException("HTTP " + response.statusCode());
return response;
}
public static void main(String[] args) throws Exception {
List<String> ips = List.of("80.82.77.139", "1.1.1.1", "8.8.8.8"); // ... e.g. 1,000 IPs to scan
Map<String, String> results = new ConcurrentHashMap<>();
// 1. Single request first: discover the current limits
HttpResponse<String> first = callApiVoid(ips.get(0));
results.put(ips.get(0), first.body());
// 2. Pool size comes from the concurrency limit
int maxWorkers = Integer.parseInt(
first.headers().firstValue("X-Concurrency-Limit").orElse("1")); // Default to 1 if not present
// 3. Pacing comes from the requests-per-second limit: Limit / Window
int rpsLimit = Integer.parseInt(
first.headers().firstValue("X-RateLimit-Limit").orElse(String.valueOf(maxWorkers)));
int rpsWindow = Integer.parseInt(
first.headers().firstValue("X-RateLimit-Window").orElse("1"));
intervalNanos = 1_000_000_000L * Math.max(1, rpsWindow) / rpsLimit;
// 4. Process the rest with a thread pool sized to the concurrency limit.
// A failed IP is reported and skipped, so one error never stops the batch.
AtomicInteger failed = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(maxWorkers);
for (String ip : ips.subList(1, ips.size())) {
pool.submit(() -> {
try {
acquireRateLimit();
results.put(ip, callApiVoid(ip).body());
} catch (Exception error) { // HTTP error (e.g. 429), connection error or timeout
failed.incrementAndGet();
System.out.println(ip + " failed: " + error.getMessage());
}
});
}
pool.shutdown();
pool.awaitTermination(1, TimeUnit.HOURS); // Wait for all submitted tasks to finish
System.out.println("Scanned " + results.size() + " IPs with " + maxWorkers
+ " workers, " + failed.get() + " failed");
}
}A few practical notes:
- The limits are per API service, so discover them separately for each API you batch against.
- If other processes or servers share the same account API keys, leave a little headroom (e.g.
max_workers = limit - 1) so they are not starved, since all of them draw on one account-wide budget, and watchX-Concurrency-RemainingandX-RateLimit-Remainingto see when a sibling process is competing with you. - These examples read the headers once, from the first response, which is enough for a short batch. A long-running worker should re-read them on every response and adjust its pacing, so a limit change reaches it without a restart.
- These examples report a failed IP and move on, so one error never stops the batch, but they do not retry: in production, combine this pattern with the retry logic so an occasional
429or transient error inside the pool is retried instead of counted as a failure, which is especially important for continuous workloads.