VerifyMail
Docs · v2026-04

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.

Positioning: Email verification tells you the address exists. VerifyMail tells you whether the person behind it is real.

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
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

signup.js
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;
}
The canonical handler. Map 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:

shell
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.com

Revoke 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:

response headers
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 412
X-RateLimit-Reset: 1746920460

Over-limit requests return 429 Too Many Requests with Retry-After: <seconds> and error.code = "too_many_requests". A simple back-off:

retry on 429
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.

idempotent check
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.

When to use it: any time a network retry could double-charge — browser-side fetches that timeout, queue workers with at-least-once delivery, CI/CD scripts that may be re-run.

POST /v1/check

The single core endpoint. Accepts an email, returns a full 5-block scored response.

Request

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

200 OK · application/json
{
  "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.

request
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×.

request
POST /v1/check/bulk
X-API-Key: $VERIFYMAIL_KEY
Content-Type: application/json

{"emails": ["a@example.com", "b@example.com", "c@example.com"]}
200 OK · response
{
  "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.

response · application/x-ndjson
{"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.

consume in Node
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.

request
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
}
202 Accepted
{
  "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

Dashboard-only endpoints. These require a logged-in session bearer, not an API key. Manage your lists in the Domain Activitypage; we'll expose API-key access in a later release.

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.

endpoint shape
# 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.com

Allow 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.

request
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.

200 OK
{
  "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.

200 OK
{
  "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:

BlockPurpose
metaRequest fingerprint: request_id, email, timing, API version, cache hit flag.
verdictThe single actionable field: recommendation + human-readable summary + high-level booleans (disposable, catch_all).
scoreQuantitative output: 0–100 risk score, confidence 0–1, applied thresholds, catch-all probability detail.
signalsEvery signal that fired, its direction (risk/trust) and weight. The "why" behind the verdict.
checksWhich 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.

ValueMeaningSuggested action
blockHigh confidence this is abuse or a dead address.Refuse signup. Show a generic error.
allow_with_flagSuspicious — could still be legitimate, but route through your friction layer.Force email verification or extra onboarding step before granting full access.
allowClean. 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)

Profileblock_atflag_atconfidence_gateBest for
strict65450.85Payment or financial services — false positives acceptable.
balanced default82600.85SaaS signup, marketing tools, most B2B.
permissive92750.80Consumer apps with high-funnel priority — minimize friction.

Calibrated thresholds (post-calibration)

Profileblock_atflag_atconfidence_gate
strict55350.80
balanced70500.75
permissive85650.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

Tier-gated feature. Catch-all SMTP probing is disabled by default (it adds ~500ms of latency) and ships on Pro / Enterprise plans. Async deep checks always run it regardless of plan — see /v1/check/async.

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:

summary
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:

summary
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.

SignalCategoryDir.WeightDescription
invalid_syntaxstructuralrisk100*Email does not pass RFC 5321 syntax validation. Hard disqualifier.
no_mx_recordsdomainrisk100*Domain has no MX records — cannot receive mail. Hard disqualifier.
domain_does_not_existdomainrisk100*DNS NXDOMAIN — the domain itself is not registered. Hard disqualifier.
known_disposable_domain_high_confidenceblocklistrisk100*On the curated disposable blocklist with confidence >= 0.95. Hard disqualifier.
catch_all_new_domainsmtprisk85Named compound: catch-all configured on a newly registered domain. Replaces both component signals.
impossible_address_on_legit_providerstructuralrisk85Local part uses chars a major provider's signup form rejects. Compound — forces allow_with_flag.
known_disposable_domainblocklistrisk75On the disposable blocklist with confidence 0.70–0.95.
mx_known_disposable_infrastructureinfrarisk75MX host fingerprint matches known disposable email providers.
unicode_homograph_domainstructuralrisk70Domain contains Cyrillic/Greek lookalike characters (homograph attack).
domain_age_under_7_daysdomainrisk68Domain registered less than 7 days ago.
cross_customer_abuse_patternbehavrisk35Domain has triggered abuse signals across multiple unrelated customers.
suspicious_mx_infrastructureinfrarisk30MX cluster shares records with confirmed disposable domains.
catch_all_domainsmtprisk30SMTP probe accepted a random-UUID recipient (catch-all).
new_domain_30ddomainrisk25Domain registered within the last 30 days.
abuse_pattern_detectedbehavrisk25Per-customer signup velocity / pattern anomaly on this domain.
random_local_part_patternstructuralrisk25Machine-generated local part — high entropy, low vowels, no separators (e.g. q9zk3v7x2m@).
generated_domain_patterndomainrisk20SLD matches algorithmic patterns (long digit runs, all-consonant strings, hash-like names).
unusual_local_charsstructuralrisk18Local contains RFC-valid but vanishingly rare chars (!#$%'*/=?^`{|}~).
bulk_registrarinfrarisk15Domain registered through a known bulk / cheap-tier registrar.
non_ascii_domainstructuralrisk15Domain contains non-ASCII characters (IDN); not a homograph attack.
new_domain_90ddomainrisk12Domain registered within the last 90 days (but older than 30).
role_based_addressstructuralrisk12Generic role address (admin@, info@, noreply@, support@, etc.).
suspicious_tldstructuralrisk12High-abuse TLD (.xyz, .tk, .top, .click, .icu, .cyou, etc.).
no_spf_recordinfrarisk10Domain has no SPF record published.
non_standard_localstructuralrisk10Local part contains characters outside the standard RFC 5321 charset.
domain_age_unknowndomainrisk8RDAP/WHOIS lookup failed — domain age could not be verified.
no_dmarc_recordinfrarisk8Domain has no DMARC record published.
known_legitimate_providertrusttrust-30Major mail provider (Gmail, Outlook, iCloud, Yahoo, Proton, etc.).
domain_age_over_5_yearstrusttrust-25Domain registered more than 5 years ago.
spf_dkim_dmarc_all_presenttrusttrust-20Domain has SPF + DKIM selector + DMARC all configured — standard for legitimate senders.
mx_known_legitimate_hosttrusttrust-15MX points to Google Workspace, Microsoft 365, or another known legit host.
domain_age_over_2_yearstrusttrust-15Domain registered more than 2 years ago.
catch_all_old_establishedtrusttrust-15Catch-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

request headers (from us → you)
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

verify-webhook.ts
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

verify_webhook.py
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 response
{
  "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"
  }
}
HTTPCodeMeaning
401invalid_api_keyAPI key missing or unknown.
401invalid_sessionSession expired or invalid (dashboard only).
402quota_exceededNo credits remaining. Buy a bundle to keep going.
409invalid_idempotency_keyIdempotency-Key reused with a different request body.
422invalid_requestMissing or malformed parameter (email, domain, etc.).
422validation_errorPydantic-level validation failed — see message.
422invalid_webhook_urlWebhook URL is not HTTPS or resolves to a private IP.
429too_many_requestsPer-key burst rate limit on check endpoints. See Retry-After + X-RateLimit-* headers.
429rate_limit_exceededAuth endpoint flood-control (per-IP, signup/login). Not used on /v1/check.
429report_rate_limit_exceededToo many /v1/report calls from this key in a short window.
500internal_errorTransient server error. Safe to retry with backoff.
503service_degradedA component (Redis / Postgres / DNS) is degraded. Retry shortly.
504dns_timeoutDNS 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.

DataStored?WhereRetention
DomainYesPostgres (checks + domain_stats)Until you delete your account
Full emailNoIn-memory only during the requestDiscarded on response
Per-domain verdict cache (no email)YesRedis4 hours (new domains) → 7 days (confirmed fraud)
Check rows (domain + verdict + signals)YesPostgresUntil you delete your account
IP address of the callerNo
Webhook URL + secretPer-requestDiscarded after deliveryNot 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.

GDPR posture.We act as a processor of the domain portion of signups your users provide you. Because emails are not stored, the data-subject deletion surface is limited to the domain history — request it from support and we'll purge.

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.