APIVoid logo

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:

http header
X-Concurrency-Limit: 10
X-Concurrency-Remaining: 9
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 9
X-RateLimit-Window: 1
X-RateLimit-Reset-After: 1

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

HeaderMeaning
X-Concurrency-LimitMaximum number of concurrent requests (active connections) your plan allows on this API.
X-Concurrency-RemainingHow many additional concurrent requests you can open on this API right now.
X-RateLimit-LimitMaximum number of requests you may start per window on this API.
X-RateLimit-RemainingHow many requests are left in your current window.
X-RateLimit-WindowWindow length in seconds (effective rate = Limit ÷ Window).
X-RateLimit-Reset-AfterSeconds until a request slot frees up.
X-RateLimit-ScopeOnly on 429 responses: which limit you hit, either requests or concurrency.
Retry-AfterOnly 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 header
HTTP/2 429
...
X-RateLimit-Scope: concurrency
Retry-After: 1

429 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:

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:

PlanConcurrent requests per API
Basic5 – 10
Startup20 – 25
Growth30 – 40
Business45 – 50
Enterprise50 – 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:

python
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")
php
$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);
node.js
// 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`);
go
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)
}
java
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: