Rate limits
On this page
Rate limits keep the API fast and fair for everyone. Normal use stays far below them: they are there to stop runaway loops and abuse, not to slow down a real integration.
Limits per endpoint
Limits are counted per API key. Your organisation also has a cap of three keys' worth on each endpoint, so creating more keys doesn't multiply the budget.
| Endpoint | Per API key | Per organisation |
|---|---|---|
GET /api-validation/single and GET /api-validation/single/result (shared) | 100 / minute | 300 / minute |
POST /api-validation/batch | 10 / minute | 30 / minute |
POST /api-validation/bulk | 10 uploads / minute | 30 / minute |
GET /api-validation/bulk/:id/status | 120 / minute | 360 / minute |
GET /api-validation/bulk/:id/download-result | 30 / minute | 90 / minute |
GET /api-validation/validate | 60 / minute | – |
Webhook endpoint management (/webhooks/*) | – | 120 / minute |
| Webhook test sends and resends | – | 20 / minute, 100 / hour |
Two more limits apply to everyone:
How the window works
Each limit is a token bucket. A bucket holds as many tokens as the limit (100 for single checks) and refills evenly over the window (one token every 0.6 seconds for 100 per minute). So you can send a burst of up to the full limit at once, then keep going at the steady rate. You never have to wait for a whole minute to pass.
Test keys
Test keys (sc_test_…) have their own budgets, separate from live ones: single 100 per minute, batch 10 per minute, bulk upload 5 per hour, status 120 per minute and download 30 per minute per test key, with three keys' worth per organisation. Sandbox traffic never uses up your live limits. To test your 429 handling without waiting for a real limit, send error-429@… with a test key.
Headers
Every response from a limited endpoint carries:
| Header | Meaning |
|---|---|
RateLimit-Limit | The limit that is closest to running out, for example 100. |
RateLimit-Remaining | Requests you can still send right now under that limit. |
RateLimit-Reset | Seconds until that bucket is full again. On a 429 it equals Retry-After: the seconds until the next request can succeed. |
RateLimit-Policy | Every limit on the endpoint, as limit;w=window-in-seconds, for example 100;w=60, 300;w=60 (per key, per organisation). |
A 429 also carries Retry-After: the number of seconds to wait before the next request can succeed.
The 429 response
{
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Maximum 100 requests per minute per API key.",
"data": {
"policy": "api.single",
"retryAfterSeconds": 1,
"limit": 100,
"scope": "apiKey",
"blocked": false
},
"status": 429,
"error": "Rate limit exceeded. Maximum 100 requests per minute per API key."
}| Field | Meaning |
|---|---|
code | Always RATE_LIMITED. |
message | A plain-language explanation. |
data.policy | Which limit you hit, for example api.single or api.bulk-status. |
data.retryAfterSeconds | Same as the Retry-After header. |
data.limit | The limit you hit. |
data.scope | What it is counted against: apiKey, org (your organisation) or ip. |
data.blocked | true when the address is temporarily blocked after repeated limit hits (see below). |
status, error | Kept for integrations written against older versions. error equals message. |
Temporary blocks
Sign-in, sign-up and the invalid-key limit escalate: an address that keeps sending requests after getting 429s is blocked for 15 minutes, then for 1 hour, then for 6 hours if it continues. A blocked request gets the same 429 with data.blocked: true and a long Retry-After. Respecting Retry-After never leads to a block. If you think an address was blocked by mistake, contact support.
Backing off
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function requestWithRetry(url, init = {}, { maxAttempts = 5, baseMs = 500, capMs = 60_000 } = {}) {
for (let attempt = 0; ; attempt++) {
let res;
try {
res = await fetch(url, init);
} catch (error) {
if (attempt + 1 >= maxAttempts) throw error;
await sleep(Math.random() * Math.min(capMs, baseMs * 2 ** attempt));
continue;
}
if (res.status === 429 && attempt + 1 < maxAttempts) {
// Respect the server: Retry-After is in seconds.
const retryAfter = Number(res.headers.get("retry-after") ?? 1);
await sleep(retryAfter * 1000 + Math.random() * 250);
continue;
}
if (res.status >= 500 && attempt + 1 < maxAttempts) {
// Full jitter: a random wait between 0 and the exponential cap.
await sleep(Math.random() * Math.min(capMs, baseMs * 2 ** attempt));
continue;
}
return res;
}
}
const res = await requestWithRetry(
`https://simplecold.com/api/v1/api-validation/single?email=${encodeURIComponent("[email protected]")}`,
{ headers: { Authorization: `Bearer ${process.env.SIMPLECOLD_API_KEY}` } },
);
console.log(res.status, res.headers.get("ratelimit-remaining"), await res.json());import os
import random
import time
import requests
API = "https://simplecold.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SIMPLECOLD_API_KEY']}"
def request_with_retry(method, url, max_attempts=5, base=0.5, cap=60, **kwargs):
for attempt in range(max_attempts):
last = attempt + 1 >= max_attempts
try:
res = session.request(method, url, timeout=30, **kwargs)
except requests.RequestException:
if last:
raise
time.sleep(random.uniform(0, min(cap, base * 2**attempt)))
continue
if res.status_code == 429 and not last:
# Respect the server: Retry-After is in seconds.
time.sleep(int(res.headers.get("Retry-After", 1)) + random.uniform(0, 0.25))
continue
if res.status_code >= 500 and not last:
# Full jitter: a random wait between 0 and the exponential cap.
time.sleep(random.uniform(0, min(cap, base * 2**attempt)))
continue
return res
res = request_with_retry("GET", f"{API}/api-validation/single", params={"email": "[email protected]"})
print(res.status_code, res.headers.get("RateLimit-Remaining"), res.json())<?php
const API = 'https://simplecold.com/api/v1';
function request_with_retry(string $url, int $maxAttempts = 5, float $base = 0.5, float $cap = 60): array
{
for ($attempt = 0; ; $attempt++) {
$last = $attempt + 1 >= $maxAttempts;
$headers = [];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SIMPLECOLD_API_KEY')],
CURLOPT_TIMEOUT => 30,
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
// Full jitter: a random wait between 0 and the exponential cap.
$backoff = mt_rand() / mt_getrandmax() * min($cap, $base * 2 ** $attempt);
if ($body === false) {
if ($last) {
throw new RuntimeException(curl_error($ch));
}
usleep((int) ($backoff * 1e6));
continue;
}
if ($status === 429 && !$last) {
// Respect the server: Retry-After is in seconds.
usleep((int) ($headers['retry-after'] ?? 1) * 1_000_000 + mt_rand(0, 250_000));
continue;
}
if ($status >= 500 && !$last) {
usleep((int) ($backoff * 1e6));
continue;
}
return ['status' => $status, 'headers' => $headers, 'body' => json_decode($body, true)];
}
}
$res = request_with_retry(API . '/api-validation/single?' . http_build_query(['email' => '[email protected]']));
echo $res['status'], ' ', $res['headers']['ratelimit-remaining'] ?? '-', PHP_EOL;