All posts

Handling Email Verification API Rate Limits: 429s, Retry-After, Backoff

··7 min read

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 / perSecond ms after the previous one, and no more than maxConcurrent run 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, not invalid. 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-After as 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 unknown results 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.