How to Cache Email Validation API Results
by EmailGuard Engineering
Share this article
Enjoyed this article?
More notes on building products, infrastructure, and teams.
by EmailGuard Engineering
More notes on building products, infrastructure, and teams.
Cache email validation API results by the normalized address, store the 2xx flags with a timestamp, and expire each flag on its own clock. Do not copy SMTP valid / invalid / unknown TTL tables onto a classifier. Never cache a timeout, 429, or 5xx. A skipped check is not a verdict.
This guide is the quota counterpart to fail open vs fail closed. You will leave with a cache key, a field-level TTL matrix for EmailGuard's detect API, and a Redis sketch that stays under the per-minute rate limit.
By the end, you will:
normalized, not the raw string the user typed.email_check=skipped.detection_source is live_dns.Prerequisites: A server-side detect call, Redis or another shared store if you run more than one process, and an API key with email:detect. Staging first.
LeadMagic's July 2026 developer guide publishes this table for mailbox verification:
| Verdict | Their TTL |
|---|---|
valid | 24 hours |
invalid | 7 days |
unknown | never |
disposable | 7 days |
MailCop's API guide says deliverable 24h, unknown 1 hour, undeliverable 7 days. Their Cloudflare Worker post (2026-05-21) then caches non-deliverable results for 1 hour. Same vendor, two clocks.
Those numbers assume an SMTP probe that can greylist, catch-all, or time out into unknown. EmailGuard does not return those statuses. Official Kickbox, ZeroBounce, Mailgun Validate, and Abstract docs (fetched 2026-09-03) also do not publish a customer cache-TTL table. Kickbox says it may cache hashed results for a time on their side. That is their store, not yours.
Copying valid=24h onto disposable: false is the expensive mistake. A domain that was not on a throwaway list this morning can be one this afternoon. ZeroBounce's 2026 decay report (2025 traffic, PR 2026-02-26) counted some disposable inboxes that lived 15 minutes. That is vendor traffic, not a universal half-life, but it is enough reason not to treat "not disposable" as a week-long fact.
normalized, not the typed stringCall detect once. Read data.normalized. Hash that for the key.
Jane.Doe+trial@gmail.com and janedoe@gmail.com are one Gmail mailbox after normalization. If you key on the raw input you will call the API twice and store two rows for one person.
import { createHmac } from "node:crypto";
function detectCacheKey(normalized: string): string {
const digest = createHmac("sha256", process.env.DETECT_CACHE_PEPPER!)
.update(normalized.toLowerCase())
.digest("hex");
return `eg:detect:v1:${digest}`;
}Include a schema version (v1) so you can bust every entry when you change what you store. Do not use the plaintext address as the Redis key. MailCop's Worker example does (validation:${email}). That is a PII store with a TTL, not a cache.
HMAC with a server pepper means a stolen dump is not a rainbow table of every Gmail address. It is still personal data if you can hash a signup email and hit. Delete the key on account erasure.
RFC 9111 governs HTTP caches. Put Cache-Control: private, no-store on any endpoint that takes an email query string so a CDN does not keep ?email=. Application Redis is a different layer. Do not set public max-age on detect.
Store only HTTP 2xx bodies where you actually classified the address.
| Outcome | Cache? |
|---|---|
| 2xx with flags | Yes, per the TTL matrix below |
| Client timeout / abort | No. That is fail-open, not a verdict |
| 5xx | No |
429 with Retry-After | No. Honor Retry-After. Caching 429 would hide recovery |
| 401 / 402 / 403 | No. Auth or plan. Alert ops |
email_check=skipped you wrote yourself | No. Recheck, do not freeze the skip |
If you cache a timeout as "allow," every retry in that TTL window skips detect. Attackers who can induce timeouts then get a free pass for the rest of the hour.
Store the JSON payload plus cached_at. On read, decide per field whether the age is still acceptable. A single Redis EX of 7 days is how SMTP posts simplify. Classification is not one bit.
Starting TTLs we use as an implementation default. They are not a measured optimum:
| Field | Starting TTL | Why |
|---|---|---|
syntax_validation, normalized, subaddressing | 7 days | RFC grammar does not rot. Bust on parser version, not on calendar |
role_address | 7 days | jobs@ stays a role prefix. Rare catalog edits |
public_domain | 24 hours | Consumer ESPs change slowly. Still refresh daily |
relay_domain / relay_provider | 6 hours | Provider catalogs move. Apple Hide My Email is not Gmail |
disposable: true | 6 hours | Usually sticky. Still allow a correction path; we correct data when a domain is wrong |
disposable: false | 15–60 minutes | This is the dangerous cache. New throwaway domains appear continuously |
detection_source: live_dns | Cap the whole entry at 15 minutes | Live DNS ran because curated data missed the domain. Do not freeze that miss |
suggested_email / suggested_domain | 24 hours | Catalog typos. Bust with schema version if the catalog ships |
On a cache hit, if disposable is older than its TTL, call detect again and merge. You can keep the syntax bits and refresh only disposable. That is the point of a field matrix.
Address-level vs domain-level:
normalized, role_address, subaddressing, suggestions.eg:detect:domain:v1:{hmac(domain)}) for public_domain, relay_domain, and disposable when you see burst signups on one host. A burst of user+1@temp.example / user+2@temp.example should not cost N detect calls for the same domain flags.Do not share one domain cache across tenants without a prefix. One team's allowlist is not another team's policy.
Two tabs, one user, two parallel POSTs. Without a lock you pay twice and race the writes.
const inflight = new Map<string, Promise<DetectData>>();
async function detectCached(email: string): Promise<DetectData> {
const typedKey = detectCacheKey(email.trim().toLowerCase());
const cached = await redis.get(typedKey);
if (cached) return JSON.parse(cached).data;
const pending = inflight.get(typedKey);
if (pending) return pending;
const job = (async () => {
const data = await detect(email); // GET /api/v1/emails/detect
const ttlSec = ttlSeconds(data);
if (ttlSec > 0) {
const payload = JSON.stringify({ data, cached_at: Date.now() });
await redis.set(typedKey, payload, "EX", ttlSec);
const normKey = detectCacheKey(data.normalized);
if (normKey !== typedKey) {
await redis.set(normKey, payload, "EX", ttlSec);
}
}
return data;
})();
inflight.set(typedKey, job);
try {
return await job;
} finally {
inflight.delete(typedKey);
}
}
function ttlSeconds(data: DetectData): number {
if (data.detection_source === "live_dns") return 15 * 60;
if (data.disposable) return 6 * 60 * 60;
return 15 * 60; // disposable false: short
}Write both the typed key and the normalized key so Jane+x@gmail.com can hit after jane@gmail.com already ran. In-memory Map is enough for a single Node process at low volume. Two app instances without Redis will split-brain and double spend. Redis (or Upstash, or whatever you already run) is the shared store.
normalized after the first 2xx.cache_hit / cache_miss / cache_bypass_live_dns.X-RateLimit-Remaining. The authenticated team API is 60 requests per minute per key. Double-clicks and React Strict Mode retries are how people burn that window. Abstract bills one credit per request even for invalid input. EmailGuard monthly quota counts successful calls. Caching 2xx is how you stop paying for the same mailbox twice.Plans and volume: pricing.
A user types pat+trial@gmial.com, blurs, then fixes to pat@gmail.com after your “did you mean” prompt.
syntax_validation maybe false, suggested_email present. Cache that payload under the typed HMAC for 24 hours (suggestion TTL). Do not create the user.pat@gmail.com. 2xx, disposable: false, public_domain: true, detection_source: precomputed.pat@gmail.com and normalized (pat@gmail.com after Gmail rules). TTL 15–60 minutes because disposable is false.If step 2 had been live_dns, cap TTL at 15 minutes even if other flags look stable. Curated data missed that domain once. Assume it can change again.
One TTL for the whole JSON. Syntax can live a week. disposable: false should not.
Caching unknown. We do not have that field. If you add an SMTP vendor later, follow their unknown rule, not ours.
Caching the skip. email_check=skipped must trigger a recheck, not a Redis hit.
Raw email keys. Support exports, log drains, and Redis dumps will all contain customer addresses.
HTTP caching GET /emails/detect?email= at a CDN. private, no-store on that response.
Treating ZeroBounce's 23% yearly decay as a classification TTL. That figure is mailbox invalid+risky share on their 2025 volume. Syntax does not decay 23% a year.
For a classification API, cache syntax and role for about 7 days, public domain about 24 hours, disposable true about 6 hours, and disposable false for 15–60 minutes. SMTP posts that say valid 24h / invalid 7d are talking about mailbox probes.
Cache syntax_validation: false for days. The string is still garbage tomorrow. Do not invent an "invalid mailbox" TTL. EmailGuard does not SMTP-probe.
No. EmailGuard has no SMTP unknown. Timeouts, 429, and 5xx are misses. Caching them hides recovery and can fail-open an attacker who can stall the checker.
In-memory for one process. Redis when you have more than one instance or you care about a restart. The key is HMAC of normalized, not the address.
Yes. A cache miss still calls the network. Fail open when that call dies. The cache only removes duplicate 2xx work.
DETECT_CACHE_PEPPER and a v1 key prefix.disposable: false.