How to Handle Email Validation API Errors
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.
Handle email validation API errors by HTTP status and the JSON code field, not by a single "validation failed" string. A 2xx body with disposable: true is a verdict. A 429 is you, not the address. EmailGuard uses two 429 codes: per-minute RATE_LIMIT_EXCEEDED and monthly API_QUOTA_EXCEEDED. We do not return 402 for quota. Do not retry either 429 inside the signup request. Do not show "invalid email" on 401 or 403.
This is the status-code companion to fail open vs fail closed. Policy lives there. The table below is what to parse.
code on every non-2xx body.Retry-After on per-minute 429 without looping in the request.Prerequisites: Server-side detect, email:detect on the key, a place to store email_check (ok, skipped, recheck). Staging first.
GET /api/v1/emails/detect returns the usual envelope: code, message, data. Success is HTTP 2xx and code: SUCCESS. Failures still have a machine code.
| HTTP | code | Meaning | Signup request |
|---|---|---|---|
| 2xx | SUCCESS | Classification ran | Apply flags. See disposable signup. |
| 400 | INVALID_BODY / INVALID_EMAIL | Bad request shape or unusable email param | Fix the client. |
| 401 | MISSING_TOKEN, INVALID_API_KEY, REVOKED_API_KEY | No key, wrong key, or revoked key | Fail closed for the check. Fail open or closed for the account per your outage table. Alert ops. Never "invalid email." |
| 403 | INSUFFICIENT_SCOPE | Key lacks email:detect | Same as 401. The key exists. The scope is wrong. |
| 429 | RATE_LIMIT_EXCEEDED | Per-minute window exhausted | Read Retry-After (seconds). Do not retry in this HTTP request. Fail open on ordinary signup. |
| 429 | API_QUOTA_EXCEEDED | Monthly api_requests quota | Upgrade or wait for the billing period. Same fail-open table. Do not treat as a typo. |
| 5xx | GENERIC_ERROR, DATABASE_ERROR, and peers | Our side | Timeout path. Fail open on free signup. |
| Client abort | none | Your timeout fired | Same as 5xx. See fail-open timeouts. |
RFC 6585 defines 429. RFC 9110 §10.2.3 defines Retry-After as a delay in seconds or an HTTP-date. EmailGuard sends seconds on rate-limit 429. Header names: API rate limiting.
Vendors who SMTP-probe mailboxes also return 429 when their workers pile onto MX. Their blogs then tell you to exponential-backoff four times in the request. That is a list job. Signup has a human waiting. One detect call. Then create or reject.
BounceZero and similar 2026 guides collapse every 429 into "slow down and retry." That is right for a bulk worker. It is wrong if you do not read code.
Per-minute (RATE_LIMIT_EXCEEDED). You double-submitted, validated on every keystroke, or shared one key across a bursty import. Headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset (seconds until the fixed one-minute window resets), plus Retry-After on the 429. Keys do not share a window. Do not mint a second key to dodge this.
Monthly (API_QUOTA_EXCEEDED). Same HTTP 429. Different code. Retry-After will not restore quota. Pricing and billing usage will. Cache 2xx results so a refresh does not spend another unit. See how to cache detect results.
If you only switch on status === 429, you will sleep 42 seconds on a monthly cap and still fail.
Greylist blogs tell you to wait 5–15 minutes and probe again because RFC 6647 4yz is temporary. EmailGuard does not RCPT. A timeout here is DNS or our process, not a mailbox greylist. Recheck in the background after you fail open. Do not hold the signup form.
AWS's jittered backoff (Architecture Blog, still the standard write-up) belongs on a queue worker that retries skipped rows. Put full jitter there. Do not put four attempts in handleSubmit.
type Envelope = { code: string; message?: string };
export function classifyDetectFailure(
status: number,
body: Envelope | null,
): "auth" | "rate" | "quota" | "outage" | "client" {
if (status === 401 || status === 403) return "auth";
if (status === 429 && body?.code === "API_QUOTA_EXCEEDED") return "quota";
if (status === 429) return "rate";
if (status >= 500) return "outage";
return "client";
}
export function signupAfterDetectError(kind: ReturnType<typeof classifyDetectFailure>) {
if (kind === "auth") {
// page ops; create account only if you fail open on outages
}
// rate | quota | outage: fail open on ordinary signup, store email_check=skipped
}Log status, code, Retry-After, and a request id. Do not log the raw email in a public channel.
INVALID_EMAIL on 400 means the query param failed our parser. That is a client bug or a truly unusable string. It is not disposable. Do not reuse the disposable copy.
MISSING_TOKEN vs INVALID_API_KEY vs REVOKED_API_KEY all land on 401. Revoked is the one you should page as a security event. The other two are usually a missing env var in the next deploy.
INSUFFICIENT_SCOPE is 403. The key is real. You minted it without email:detect, or you used a dashboard session token on /api/v1. Create a key with the detect scope. Do not widen the key to “everything” to silence the error.
Monthly quota middleware runs when billing is on. Local stacks with billing off will never see API_QUOTA_EXCEEDED. Staging should turn billing on once before launch so you test that branch.
Headers on a successful detect still include X-RateLimit-*. Log remaining on 2xx in staging so you see the window drain before you ever hit 429. Do not parse those headers in the browser. The key never belongs there.
If you wrap detect in a queue, treat RATE_LIMIT_EXCEEDED as a delay using Retry-After and treat API_QUOTA_EXCEEDED as a dead letter until someone upgrades. Same HTTP status. Different queues.
Retrying 429 in the request. You add latency and make the window worse.
Mapping quota to 402. Payment Required is not what we send. Read API_QUOTA_EXCEEDED.
Showing the API message on the form. Users get "Rate limit exceeded, please try again in 42s" and think the address is wrong.
Treating 401 as a disposable block. You shipped a bad secret.
Validating on input. You will 429 yourself. Validate on submit. Debounce imports.
EmailGuard returns 429 with RATE_LIMIT_EXCEEDED for the per-minute window. Monthly quota is also 429, with API_QUOTA_EXCEEDED.
No. Honor Retry-After in a background recheck. Create or reject once.
No. It is a missing, invalid, or revoked key.
We do not use 402 for detect. Quota is 429 plus API_QUOTA_EXCEEDED.
Plan limits and usage live under billing in the dashboard. Per-minute remaining is X-RateLimit-Remaining on authenticated /api/v1 responses.
code in one helper. Do not switch on HTTP alone.