VerifyMail API
A single endpoint that tells you whether the person behind an email is real. Confidence-weighted risk scores, explainable signals, and catch-all detection built in.
Introduction
VerifyMail identifies throwaway, disposable, catch-all, and abusive email addresses at signup. Instead of a boolean “valid/invalid” flag, every call to /v1/check returns a 5-block response — meta, verdict, score, signals, and checks — so you can make nuanced policy decisions from a single request.
The entire API is intentionally small. One endpoint handles 99% of traffic. Supporting endpoints let you report false positives, monitor usage, and subscribe to async webhooks.
Quickstart
Get an API key (prefix dc_) from the dashboard and make your first check in under 60 seconds. Official SDKs are on the way; the API is plain HTTP + JSON in the meantime.
1. Make a check
curl -X POST https://api.verifymailapi.com/v1/check \
-H "X-API-Key: $VERIFYMAIL_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "test@mailinator.com"}'2. Branch on the recommendation
const res = await fetch("https://api.verifymailapi.com/v1/check", {
method: "POST",
headers: { "X-API-Key": process.env.VERIFYMAIL_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const result = await res.json();
switch (result.verdict.recommendation) {
case "block":
return reject("This email cannot be used.");
case "allow_with_flag":
// Suspicious — route through your verification step.
user.requires_email_verification = true;
await sendVerificationEmail(user);
break;
case "allow":
// Clean — proceed normally.
break;
}allow_with_flag to requires_verification: true on your user record, then force the user through your existing email-verification or friction step. Most apps already have one — the flag costs you zero new code.Authentication
Every request requires an API key. Keys are prefixed dc_ and created in the dashboard. Two header forms are accepted; pick whichever fits your client:
export VERIFYMAIL_KEY="dc_..."
# Preferred:
curl -H "X-API-Key: $VERIFYMAIL_KEY" \
https://api.verifymailapi.com/v1/check?email=test@example.com
# Also accepted (useful when a client only allows Authorization):
curl -H "Authorization: Bearer $VERIFYMAIL_KEY" \
https://api.verifymailapi.com/v1/check?email=test@example.comRevoke a key from the dashboard at any time — revocation takes effect immediately, so plan your rollover by issuing the new key first, updating your deploy, then revoking the old one.
Rate limits
Default ceiling is 600 requests / minute per API key. Every response includes three headers so your client can back off before hitting the wall:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 412
X-RateLimit-Reset: 1746920460Over-limit requests return 429 Too Many Requests with Retry-After: <seconds> and error.code = "too_many_requests". A simple back-off:
if (res.status === 429) {
const wait = Number(res.headers.get("Retry-After") ?? 1) * 1000;
await new Promise(r => setTimeout(r, wait));
return retry(req);
}Need a higher ceiling? Contact support — per-key limits are configurable.
Idempotency
Every credit-debiting POST endpoint accepts an Idempotency-Key header. Replay the same key within 24 hours and you get the cached response back, with no duplicate charge. Reusing the same key with a different request body returns 409 invalid_idempotency_key.
POST /v1/check
Idempotency-Key: 2c4f9e1a-7b88-4a3d-9c0f-1e3a5b7c8d9e
X-API-Key: $VERIFYMAIL_KEY
Content-Type: application/json
{"email": "user@example.com"}Use any opaque string (UUID v4 is conventional). Applies to /v1/check, /v1/check/domain, /v1/check/bulk, and /v1/check/async. The streaming bulk variant deliberately does not support idempotency.
POST /v1/check
The single core endpoint. Accepts an email, returns a full 5-block scored response.
Request
POST https://api.verifymailapi.com/v1/check
X-API-Key: dc_...
X-Risk-Profile: balanced # optional — strict | balanced | permissive
Content-Type: application/json
{ "email": "user@myagency-solutions.com" }Response — fraud-domain example
{
"meta": {
"request_id": "req_8d4b3e2f1a6c",
"email": "user@myagency-solutions.com",
"domain": "myagency-solutions.com",
"checked_at": "2026-04-21T10:24:12.391Z",
"latency_ms": 94,
"api_version": "2026-04",
"model_phase": "bootstrap",
"model_version": "1.0.0",
"path_taken": "standard",
"cached": false,
"cache_age_seconds": null
},
"verdict": {
"recommendation": "block",
"risk_level": "critical",
"disposable": false,
"catch_all": null,
"catch_all_checked": false,
"valid_address": true,
"safe_to_send": false,
"summary": "Domain registered 4 days ago and matches known disposable infrastructure. Blocked."
},
"score": {
"value": 100,
"confidence": 0.9,
"confidence_level": "high",
"components": {
"strong_signals": 143,
"corroborating": 12,
"trust_adjustments": 0,
"compounding_bonus": 0,
"final_clamped": 100
},
"thresholds": {
"block_at": 82,
"flag_at": 60,
"your_profile": "balanced"
},
"catch_all_detail": null
},
"signals": {
"fired": [
{ "name": "domain_age_under_7_days", "direction": "risk", "weight": 68 },
{ "name": "mx_known_disposable_infrastructure", "direction": "risk", "weight": 75 },
{ "name": "suspicious_tld", "direction": "risk", "weight": 12 }
],
"trust_signals": [],
"compounding": { "applied": false, "signal_count": 1, "bonus_applied": 0, "explanation": "" }
}
}POST /v1/check/domain
Use when you already have a domain (not an email) and don't need us to syntax-validate an address. Same engine, same 1-credit cost. meta.email is blanked in the response.
POST /v1/check/domain
X-API-Key: $VERIFYMAIL_KEY
Content-Type: application/json
{"domain": "example.com"}POST /v1/check/bulk
Submit up to 100 emails per request. Charges N credits up front (all-or-nothing — if your balance is below N, the request 402s without a partial debit). Internally bounded to 10 concurrent checks, so a 100-row batch finishes in roughly 10× a single check rather than 100×.
POST /v1/check/bulk
X-API-Key: $VERIFYMAIL_KEY
Content-Type: application/json
{"emails": ["a@example.com", "b@example.com", "c@example.com"]}{
"items": [ /* CheckResponse, CheckResponse, ... */ ],
"summary": {
"total": 3,
"credits_charged": 3,
"credits_remaining": 8742,
"elapsed_ms": 327
}
}Order is preserved — items[i] matches emails[i]. Invalid-syntax emails produce a CheckResponse with recommendation: "block"; individual rows never error.
POST /v1/check/bulk/stream
Same input as /v1/check/bulk, but emits one JSON line per row as it finishes. Use for large batches (5k–100k addresses) when you want to start processing results before the full job completes.
{"index": 4, "result": { /* CheckResponse */ }}
{"index": 0, "result": { /* CheckResponse */ }}
{"index": 1, "result": { /* CheckResponse */ }}
{"index": 2, "result": { /* CheckResponse */ }}
{"index": 3, "result": { /* CheckResponse */ }}
{"event": "summary", "total": 5, "credits_charged": 5, "credits_remaining": 8737, "elapsed_ms": 612}Results stream in finish-order, not input-order — correlate via the index field. The final line is always a {event: "summary"} object.
const res = await fetch(url, { method: "POST", headers, body });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop()!;
for (const line of lines) {
if (!line) continue;
const evt = JSON.parse(line);
if (evt.event === "summary") console.log("done", evt);
else processRow(evt.index, evt.result);
}
}POST /v1/check/async
Two-phase verification. Returns a 202 immediately with a preliminary verdict from the fast path, then runs the deep SMTP / catch-all probe in the background and POSTs the final result to your webhook.
POST /v1/check/async
X-API-Key: $VERIFYMAIL_KEY
Content-Type: application/json
{
"email": "user@example.com",
"webhook_url": "https://your-app.example/webhooks/verifymail",
"webhook_secret": "..." // optional, any string — used as the HMAC-SHA256 key
}{
"request_id": "req_8d4b3e2f1a6c",
"status": "pending",
"preliminary": { /* CheckResponse from fast/standard layers */ },
"webhook_url": "https://your-app.example/webhooks/verifymail",
"estimated_completion_ms": 6000
}When the deep path completes, we POST the final CheckResponse to your webhook_url with HMAC signature headers — see Webhook signatures. The webhook URL must be HTTPS and resolve to a public IP; private addresses are rejected.
/v1/lists — custom allow / block
Per-account custom allow / block lists. Domains on the allow list always return allow; domains on the block list always return block. Both bypass the engine and the credit charge — verdicts come straight from Redis in <2ms.
# List entries
GET /v1/lists/allow
GET /v1/lists/block
# Add an entry
POST /v1/lists/allow {"domain": "ourcustomerdomain.com"}
POST /v1/lists/block {"domain": "abusivedomain.shop"}
# Remove an entry
DELETE /v1/lists/allow/ourcustomerdomain.comAllow takes precedence over block when a domain ends up on both.
POST /v1/report
Tell us when a domain you saw turned out to be confirmed throwaway, confirmed legitimate, or suspected. Reports feed the network-effect model — your reports tune your future verdicts and (de-identified) help other customers.
POST /v1/report
X-API-Key: $VERIFYMAIL_KEY
Content-Type: application/json
{
"domain": "weirddomain.shop",
"outcome": "confirmed_throwaway",
"notes": "User signed up, used trial, never paid, never returned."
}GET /v1/usage/me
Programmatic equivalent of the dashboard's Usage summary. Returns current-period totals plus the credit balance so your monitoring stack can read them without scraping the UI.
{
"total_checks": 41203,
"checks_this_period": 9128,
"period_start": "2026-05-01T00:00:00+00:00",
"blocks": 612,
"allow_with_flag": 240,
"allows": 8276,
"avg_latency_ms": 42.1,
"cache_hit_rate": 0.71,
"credit_balance_checks": 8742
}GET /v1/status
Component-level health. Always returns 200 — read the per-component fields under components to diagnose. Distinct from /health, which is a binary liveness probe used by load balancers.
{
"status": "ok", // or "degraded"
"components": {
"redis": "ok",
"postgres": "ok",
"dns": "ok"
},
"latency_ms": 18
}The 5-block structure
Every response has the same top-level shape. Each block has a single purpose:
| Block | Purpose |
|---|---|
meta | Request fingerprint: request_id, email, timing, API version, cache hit flag. |
verdict | The single actionable field: recommendation + human-readable summary + high-level booleans (disposable, catch_all). |
score | Quantitative output: 0–100 risk score, confidence 0–1, applied thresholds, catch-all probability detail. |
signals | Every signal that fired, its direction (risk/trust) and weight. The "why" behind the verdict. |
checks | Which physical probes ran (DNS, SMTP, blocklist) and how long each took. Useful for debugging latency. |
The 3 recommendation values
The verdict.recommendation field is always one of exactly three strings — switch on it directly without parsing thresholds.
| Value | Meaning | Suggested action |
|---|---|---|
| block | High confidence this is abuse or a dead address. | Refuse signup. Show a generic error. |
| allow_with_flag | Suspicious — could still be legitimate, but route through your friction layer. | Force email verification or extra onboarding step before granting full access. |
| allow | Clean. No material risk signals. | Proceed normally. |
Risk profiles
Three built-in profiles control the block/flag thresholds applied to the raw 0–100 score. Send X-Risk-Profile: strict | balanced | permissive per request, or set an account default.
Two threshold sets exist: bootstrap (stricter — used while we are still gathering calibration data) and calibrated (once we have enough confirmed outcomes for your traffic). Bootstrap is the current production default. The active phase is exposed as meta.model_phase in every response.
Bootstrap thresholds (current default)
| Profile | block_at | flag_at | confidence_gate | Best for |
|---|---|---|---|---|
| strict | 65 | 45 | 0.85 | Payment or financial services — false positives acceptable. |
| balanced default | 82 | 60 | 0.85 | SaaS signup, marketing tools, most B2B. |
| permissive | 92 | 75 | 0.80 | Consumer apps with high-funnel priority — minimize friction. |
Calibrated thresholds (post-calibration)
| Profile | block_at | flag_at | confidence_gate |
|---|---|---|---|
| strict | 55 | 35 | 0.80 |
| balanced | 70 | 50 | 0.75 |
| permissive | 85 | 65 | 0.70 |
Confidence gate: a high score with low confidence never auto-blocks. It surfaces as allow_with_flag instead — the single rule that prevents most false positives.
Catch-all detection
Catch-all domains accept mail for any address, which defeats naive SMTP probes that just check "does this mailbox exist?". VerifyMail sends a random UUID-local-part RCPT TO over SMTP and reads the response. The outcome plus the surrounding signals decide what the verdict should be:
- SMTP 250 to a random recipient → catch-all confirmed (probability 0.85).
- SMTP 550 or similar reject → not a catch-all (probability 0.05).
- Timeout / connection failure → inconclusive; we add a confidence penalty rather than guessing.
The probe result is then weighted against the rest of the signal set. A catch-all on a 14-day-old domain with disposable-MX infrastructure is fraud; the exact same probe result on a 6-year-old domain with proper SPF/DKIM/DMARC is normal B2B traffic. Both produce catch_all_detail.detected: true; only the surrounding signals (and the resulting legitimate_use_likely boolean) tell them apart.
catch_all_detail.type is one of confirmed, suspected, or cleared.
Worked example A — fraud catch-all
Domain registered 4 days ago, MX matches known disposable infrastructure, SMTP accepts a random-UUID recipient:
catch_all_detail.detected = true
catch_all_detail.probability = 0.85
catch_all_detail.type = "confirmed"
catch_all_detail.legitimate_use_likely = false
signals.fired = ["catch_all_new_domain", "mx_known_disposable_infrastructure"]
verdict.recommendation = "block"Worked example B — established catch-all
Domain registered 6+ years ago, SPF + DMARC published, SMTP accepts a random-UUID recipient:
catch_all_detail.detected = true
catch_all_detail.probability = 0.85
catch_all_detail.type = "confirmed"
catch_all_detail.legitimate_use_likely = true
signals.trust_signals = ["catch_all_old_established", "spf_dkim_dmarc_all_present", "domain_age_over_5_years"]
verdict.recommendation = "allow_with_flag"Signals reference
The full signal registry. Risk signals add to the score; trust signals subtract. Hard disqualifiers exit the pipeline early at score=100 with no further evaluation.
| Signal | Category | Dir. | Weight | Description |
|---|---|---|---|---|
invalid_syntax | structural | risk | 100* | Email does not pass RFC 5321 syntax validation. Hard disqualifier. |
no_mx_records | domain | risk | 100* | Domain has no MX records — cannot receive mail. Hard disqualifier. |
domain_does_not_exist | domain | risk | 100* | DNS NXDOMAIN — the domain itself is not registered. Hard disqualifier. |
known_disposable_domain_high_confidence | blocklist | risk | 100* | On the curated disposable blocklist with confidence >= 0.95. Hard disqualifier. |
catch_all_new_domain | smtp | risk | 85 | Named compound: catch-all configured on a newly registered domain. Replaces both component signals. |
impossible_address_on_legit_provider | structural | risk | 85 | Local part uses chars a major provider's signup form rejects. Compound — forces allow_with_flag. |
known_disposable_domain | blocklist | risk | 75 | On the disposable blocklist with confidence 0.70–0.95. |
mx_known_disposable_infrastructure | infra | risk | 75 | MX host fingerprint matches known disposable email providers. |
unicode_homograph_domain | structural | risk | 70 | Domain contains Cyrillic/Greek lookalike characters (homograph attack). |
domain_age_under_7_days | domain | risk | 68 | Domain registered less than 7 days ago. |
cross_customer_abuse_pattern | behav | risk | 35 | Domain has triggered abuse signals across multiple unrelated customers. |
suspicious_mx_infrastructure | infra | risk | 30 | MX cluster shares records with confirmed disposable domains. |
catch_all_domain | smtp | risk | 30 | SMTP probe accepted a random-UUID recipient (catch-all). |
new_domain_30d | domain | risk | 25 | Domain registered within the last 30 days. |
abuse_pattern_detected | behav | risk | 25 | Per-customer signup velocity / pattern anomaly on this domain. |
random_local_part_pattern | structural | risk | 25 | Machine-generated local part — high entropy, low vowels, no separators (e.g. q9zk3v7x2m@). |
generated_domain_pattern | domain | risk | 20 | SLD matches algorithmic patterns (long digit runs, all-consonant strings, hash-like names). |
unusual_local_chars | structural | risk | 18 | Local contains RFC-valid but vanishingly rare chars (!#$%'*/=?^`{|}~). |
bulk_registrar | infra | risk | 15 | Domain registered through a known bulk / cheap-tier registrar. |
non_ascii_domain | structural | risk | 15 | Domain contains non-ASCII characters (IDN); not a homograph attack. |
new_domain_90d | domain | risk | 12 | Domain registered within the last 90 days (but older than 30). |
role_based_address | structural | risk | 12 | Generic role address (admin@, info@, noreply@, support@, etc.). |
suspicious_tld | structural | risk | 12 | High-abuse TLD (.xyz, .tk, .top, .click, .icu, .cyou, etc.). |
no_spf_record | infra | risk | 10 | Domain has no SPF record published. |
non_standard_local | structural | risk | 10 | Local part contains characters outside the standard RFC 5321 charset. |
domain_age_unknown | domain | risk | 8 | RDAP/WHOIS lookup failed — domain age could not be verified. |
no_dmarc_record | infra | risk | 8 | Domain has no DMARC record published. |
known_legitimate_provider | trust | trust | -30 | Major mail provider (Gmail, Outlook, iCloud, Yahoo, Proton, etc.). |
domain_age_over_5_years | trust | trust | -25 | Domain registered more than 5 years ago. |
spf_dkim_dmarc_all_present | trust | trust | -20 | Domain has SPF + DKIM selector + DMARC all configured — standard for legitimate senders. |
mx_known_legitimate_host | trust | trust | -15 | MX points to Google Workspace, Microsoft 365, or another known legit host. |
domain_age_over_2_years | trust | trust | -15 | Domain registered more than 2 years ago. |
catch_all_old_established | trust | trust | -15 | Catch-all detected but on a well-established, well-authenticated domain — likely legitimate B2B use. |
Weights marked with * are hard disqualifiers: they force recommendation: "block" and short-circuit the rest of the pipeline. Corroborating signals compound non-linearly (1.0× / 1.3× / 1.6× / 1.9×) — see the 5-block structure.
Webhook signatures
When you call /v1/check/async with a webhook_secret, the final check.completed event is signed with HMAC-SHA256 of the raw request body. Verify the signature in your handler before trusting the payload — anyone can POST to a public URL.
Headers we send
Content-Type: application/json
User-Agent: VerifyMail-Webhook/1.0
X-VerifyMail-Request-Id: req_abc123
X-VerifyMail-Event: check.completed
X-VerifyMail-Signature: sha256=8c2a5b3e...Verifying in Node.js
import crypto from "node:crypto";
function verify(rawBody: Buffer, signatureHeader: string, secret: string) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// timingSafeEqual avoids leaking secret length via timing.
const a = Buffer.from(signatureHeader);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express handler — use express.raw, NOT express.json, so rawBody is preserved.
app.post("/webhooks/verifymail", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.header("X-VerifyMail-Signature") ?? "";
if (!verify(req.body, sig, process.env.VERIFYMAIL_WEBHOOK_SECRET!)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// event.result is the full CheckResponse.
await handleCheckCompleted(event);
res.status(200).end();
});Verifying in Python
import hmac, hashlib
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode("utf-8"), raw_body, hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature_header)
# In FastAPI: use Request.body() — JSON parsing happens after verification.
@app.post("/webhooks/verifymail")
async def webhook(request: Request):
raw = await request.body()
sig = request.headers.get("X-VerifyMail-Signature", "")
if not verify(raw, sig, os.environ["VERIFYMAIL_WEBHOOK_SECRET"]):
raise HTTPException(status_code=401, detail="bad signature")
event = json.loads(raw)
await handle_check_completed(event)
return {"ok": True}Retry policy
We retry non-2xx responses up to 3 times with exponential backoff (1s, 5s, 25s). 4xx errors (except 408/429) are not retried — fix them on your side and we'll stop hammering you.
Error codes
All errors return a consistent envelope:
{
"error": {
"code": "too_many_requests",
"http_status": 429,
"message": "Rate limit exceeded — over 600 req/min on this key. Retry after the window resets.",
"request_id": "req_8d4b3e2f1a6c",
"docs_url": "https://verifymailapi.com/docs/rate-limits",
"limit": 600,
"reset_at": "2026-05-11T14:30:00Z"
}
}| HTTP | Code | Meaning |
|---|---|---|
| 401 | invalid_api_key | API key missing or unknown. |
| 401 | invalid_session | Session expired or invalid (dashboard only). |
| 402 | quota_exceeded | No credits remaining. Buy a bundle to keep going. |
| 409 | invalid_idempotency_key | Idempotency-Key reused with a different request body. |
| 422 | invalid_request | Missing or malformed parameter (email, domain, etc.). |
| 422 | validation_error | Pydantic-level validation failed — see message. |
| 422 | invalid_webhook_url | Webhook URL is not HTTPS or resolves to a private IP. |
| 429 | too_many_requests | Per-key burst rate limit on check endpoints. See Retry-After + X-RateLimit-* headers. |
| 429 | rate_limit_exceeded | Auth endpoint flood-control (per-IP, signup/login). Not used on /v1/check. |
| 429 | report_rate_limit_exceeded | Too many /v1/report calls from this key in a short window. |
| 500 | internal_error | Transient server error. Safe to retry with backoff. |
| 503 | service_degraded | A component (Redis / Postgres / DNS) is degraded. Retry shortly. |
| 504 | dns_timeout | DNS resolution timed out mid-check. Safe to retry. |
Privacy & data handling
VerifyMail is deliberately conservative about PII. The short version: we never store full email addresses, only the domain portion.
| Data | Stored? | Where | Retention |
|---|---|---|---|
| Domain | Yes | Postgres (checks + domain_stats) | Until you delete your account |
| Full email | No | In-memory only during the request | Discarded on response |
| Per-domain verdict cache (no email) | Yes | Redis | 4 hours (new domains) → 7 days (confirmed fraud) |
| Check rows (domain + verdict + signals) | Yes | Postgres | Until you delete your account |
| IP address of the caller | No | — | — |
| Webhook URL + secret | Per-request | Discarded after delivery | Not stored at rest |
When a verdict is returned, the full email is in the response meta.email field for your records, but our cache strips it before writing. Your account is the only one that ever sees it. We also do not log full emails to access logs — use POST /v1/check in production rather than the GET variant to keep emails out of query strings.
Versioning
The API is currently at /v1/. Every response includes the schema version under meta.api_version (currently 2026-04) so you can detect drift in your logs.
Breaking changes will ship on a new URL prefix (/v2/) with at least 6 months of overlap and a migration guide published before the cutover. Backward- compatible additions (new optional fields, new endpoints, new signal names) ship on /v1/ at any time — your code should ignore unknown fields rather than failing on them.