Handling Email Verification API Rate Limits: 429s, Retry-After, Backoff
To handle email verification API rate limits, pace requests just under the vendor's published limit with a client-side limiter that caps both requests per second and requests in flight. On a 429, wait for Retry-After, pause every worker sharing the key, then retry. Back off with jitter for other temporary errors, cap the attempts, and never retry inline on a signup form.
We tested this on 6 October 2026 against a local mock API that enforces 10 requests per rolling second and 3 concurrent requests, which is the shape of many real limits. The numbers below come from that run. They show the pattern, not any vendor's real performance.
Two kinds of limit, often both at once
Vendors limit how many requests you start per time window, how many you have in flight, or both. Your limiter has to respect whichever is tighter.
From the vendor docs we read for email verification APIs compared (1 October 2026):
| Vendor | Published single-check limit |
|---|---|
| Kickbox | 25 parallel requests per IP; 8,000 per clock minute |
| Emailable | 25 per second |
| Hunter | 10 per second and 300 per minute |
| Bouncer | 1,000 per minute by default |
| DeBounce | 5 concurrent calls |
Two of those are concurrency limits, and those catch people out. Five concurrent calls is generous if each takes 200 ms. If the vendor is waiting on a slow mail server and each call takes 10 seconds, you get 30 calls a minute, and a requests-per-second limiter alone will blow straight past it.
What 429 and Retry-After actually promise
429 means "you sent too many requests". Retry-After is optional and comes either as seconds or as an HTTP date.
RFC 6585 defines 429 Too Many Requests: the response "MAY include a Retry-After header indicating how long to wait before making a new request." It also says the spec "does not define how the origin server identifies the user, nor how it counts requests". The limit might be per key, per IP or per endpoint, which is why you need to read your vendor's docs.
RFC 9110 section 10.2.3 defines the header: "The Retry-After field value can be either an HTTP-date or a number of seconds to delay after receiving the response." Parse both. Code that does Number(header) gets NaN for a date and retries immediately.
Some vendors also send x-ratelimit-* headers with the remaining quota. Those are vendor conventions. An IETF working draft, draft-ietf-httpapi-ratelimit-headers, proposes standard RateLimit and RateLimit-Policy fields, but it is still an Internet-Draft, so parse whatever your vendor actually documents.
The client: pace, cap, retry
A limiter that spaces request starts and caps concurrency, plus a retry loop that honours Retry-After and uses full jitter.
export class RateLimiter {
constructor({ perSecond, maxConcurrent }) {
this.interval = 1000 / perSecond;
this.maxConcurrent = maxConcurrent;
this.nextSlot = 0;
this.active = 0;
this.waiting = [];
}
async run(task) {
await this.#acquire();
try {
return await task();
} finally {
this.active--;
this.waiting.shift()?.();
}
}
pause(ms) {
this.nextSlot = Math.max(this.nextSlot, Date.now() + ms);
}
async #acquire() {
while (this.active >= this.maxConcurrent) {
await new Promise((resolve) => this.waiting.push(resolve));
}
this.active++;
const now = Date.now();
const slot = Math.max(now, this.nextSlot);
this.nextSlot = slot + this.interval;
if (slot > now) await new Promise((r) => setTimeout(r, slot - now));
}
}
function retryAfterMs(header) {
if (!header) return null;
if (/^\d+$/.test(header.trim())) return Number(header) * 1000;
const date = Date.parse(header);
return Number.isNaN(date) ? null : Math.max(0, date - Date.now());
}
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
export async function verifyWithRetry(limiter, email, { maxAttempts = 5, baseMs = 500, capMs = 30_000 } = {}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
let res;
try {
res = await limiter.run(() =>
fetch(`${process.env.VERIFY_URL}?email=${encodeURIComponent(email)}`, {
headers: { Authorization: `Bearer ${process.env.VERIFY_KEY}` },
signal: AbortSignal.timeout(15_000),
}),
);
} catch {
res = null; // network error or timeout: retry like a 503
}
if (res?.ok) return { email, ...(await res.json()) };
if (res && !RETRYABLE.has(res.status)) {
return { email, result: "error", httpStatus: res.status };
}
const backoff = Math.random() * Math.min(capMs, baseMs * 2 ** attempt);
const wait = Math.max(retryAfterMs(res?.headers.get("retry-after")) ?? 0, backoff);
if (res?.status === 429) limiter.pause(wait);
await new Promise((r) => setTimeout(r, wait));
}
return { email, result: "retry-later" };
}
How it behaves:
- Pacing, not bursting. Each request gets a start slot
1000 / perSecondms after the previous one, and no more thanmaxConcurrentrun at once. pause()is shared. A 429 pushes the next slot back for every caller using that limiter. That's what stops the other workers from hitting the limit while one waits.- Full jitter. The backoff is a random value between zero and
base * 2^attempt, capped. This is the "Full Jitter" formula from Marc Brooker's AWS Architecture Blog post:sleep = random(0, min(cap, base * 2 ** attempt)). Jitter keeps a batch of clients that failed together from retrying together. - Only temporary errors retry. A 401 for a bad key, or an out-of-credits error, won't fix itself, so the loop returns an error straight away instead of burning five attempts.
- It gives up honestly. After five attempts the result is
retry-later, notinvalid. An address you never managed to check is not a bad address.
The limiter keeps its state in process memory. If several servers or serverless instances share one API key, each has its own limiter, so the real rate is your setting times the number of instances. Divide the vendor limit between them, or move the counter into a shared store such as Redis.
What the test showed
Pacing just under the limit was the fastest option. Going over it cost both 429s and time.
Sixty addresses, the same mock API (10 per second, 3 in flight, Retry-After: 1 on every 429):
| Client | Time | 429s received | Verified |
|---|---|---|---|
Promise.all, no limiter |
0.3 s | 57 | 3 of 60 |
| Limiter at 8/s, 3 concurrent | 7.6 s | 0 | 60 of 60 |
| Limiter at 15/s, 5 concurrent | 12.0 s | 9 | 60 of 60 |
The unlimited client "finished" fastest because it got almost nothing done. The over-limit client got everything done eventually, but each 429 forced a pause of at least a second across all workers, so it came in about 4 seconds slower than the paced one. Set the limiter to about 80% of the published limit, and treat 429s in your logs as a sign that the setting is wrong.
Rate limits are not the only reason to retry
A 429 is the vendor's HTTP limit and clears in seconds. An unknown result usually comes from the recipient's mail server and can take hours to clear.
Verification vendors run SMTP conversations with mail servers that have limits of their own. A server that greylists answers with a temporary code such as 451 4.3.0, and one that thinks it's being probed too fast may answer 421 4.7.0. The vendor turns either into unknown inside an HTTP 200.
Don't feed those back into the retry loop above. Store them, and run a separate re-check job hours later. Retrying them immediately just spends credits on the same temporary refusal. The statuses are covered in email verification statuses explained.
Signup forms vs bulk jobs
On a signup form, never retry inline. In a bulk job, retry patiently and checkpoint progress.
| Signup form | Bulk job | |
|---|---|---|
| Timeout | 2 to 3 seconds | 15 to 30 seconds |
| On 429 | Let the user in as unconfirmed | Pause, then retry |
| Attempts | One | Several, with backoff |
| Unknown | Re-check in the background | Re-check in a later pass |
| Priority | User latency | Total throughput |
If both share one API key, a bulk run can use up the quota that your signup form needs. Give the bulk job a limiter set well below the vendor limit so signups keep some headroom. Real-time email verification on signup forms covers the signup side, and bulk-verifying a CSV through an API has a resumable Python version of the bulk side.
Cut the number of calls first
The cheapest request is the one you don't send: dedupe, syntax-check locally, and cache results.
- Dedupe after trimming and lower-casing. Lists often contain the same address several times.
- Reject bad syntax and dead domains locally. A failed syntax check or a missing domain doesn't need a paid API call (syntax vs MX vs SMTP).
- Cache confirmed results for days, not forever. Mailboxes close, so set an expiry. SimpleVerifier, for example, caches confirmed results for 14 days in its web app. The cache sits behind its REST API too, so a repeat check inside that window returns the earlier result without a new mailbox probe.
The takeaway
- Limit both rate and concurrency, and set the limiter to about 80% of the vendor's published figures.
- Parse
Retry-Afteras seconds or as a date, and pause all workers on a 429. - Use exponential backoff with full jitter, retry only temporary errors, and cap the attempts.
- Keep
unknownresults out of the fast retry loop. Re-check them later. - To compare what different vendors charge per check at your volume, use the verification cost calculator.
Common questions
What should my code do when an email verification API returns 429?
Stop sending, wait for the time in the Retry-After header if there is one, and then retry that request. Pause every worker that shares the key, not only the one that got the 429, or the others keep hitting the limit. If there's no Retry-After, use exponential backoff with random jitter and a cap on attempts.
Is it faster to send as many requests as possible and retry the 429s?
No. In our test against a mock API limited to 10 requests per second and 3 in flight, a client paced just under the limit verified 60 addresses in 7.6 seconds with no 429s. A client set above the limit needed 12.0 seconds for the same 60, because every 429 costs a wait.
Should I retry an 'unknown' result the same way as a 429?
No. A 429 is the vendor's HTTP limit and clears in seconds. An unknown result usually means the recipient's mail server was slow or greylisted the check, which can take minutes or hours to clear. Store unknown results and re-check them later in a separate job.
How do I avoid rate limits on a signup form?
Verify once on submit, not on every keystroke, cache results, and never retry inline. If the API is rate-limited or slow at signup, let the user through as unconfirmed and re-check in the background.
Check your list before you send
Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.