API Reference
API Reference
GitHub ↗
Email Sending

Email Verification

Validate an email address before sending

Check whether an email address is valid and likely deliverable before you send to it. Verification runs a series of cheap-to-expensive checks (syntax, your suppression/bounce history, disposable/role detection, and an MX lookup) and caches results to avoid repeated lookups.

POST /api/v1/emails/verify

Needs emails:read. Disabled by default — set email_verify.enabled to turn it on. The MX check makes outbound DNS queries, which an operator on a locked-down network should opt into rather than discover.

Note

No SMTP probe is performed, so mailbox existence is never confirmed — checks.smtp is always reported as "skipped". A valid result means the address is syntactically correct and the domain accepts mail, not that the specific mailbox exists.

Request

curl -X POST http://localhost:3000/api/v1/emails/verify \
  -H "Authorization: Bearer myk_..." \
  -H "Content-Type: application/json" \
  -d '{"email": "j.okafor@acme-industrial.example"}'

One field, email, required. It is checked for a valid address shape by the request validator before anything else runs, so a malformed string is a 400 and never reaches a DNS lookup.

Add ?fresh=true to skip the cache and recompute. Use it when you have just fixed a domain’s DNS and want the answer now — not in a loop over a list, which is what the cache exists to protect your resolver from.

What it checks

The intrinsic checks run cheapest first and stop as soon as the answer is settled:

  1. Syntax. The address is parsed. A malformed one is invalid at score 0, and nothing else runs.
  2. Disposable domain. Matched against a set of known throwaway providers. A hit is disposable at score 10, without a DNS lookup.
  3. Role account. The local part is matched against role names such as info, admin or support. This does not short-circuit — it is recorded and applied at the end.
  4. Mail exchangers. The domain’s MX records are resolved, falling back to A and AAAA, since RFC 5321 treats a domain with only an address record as having an implicit MX. No mail servers means invalid at score 0.

Whatever survives that is risky at 60 if it was a role account, and valid at 90 otherwise.

Your project’s own history is applied last, on top of the result. Suppression and hard-bounce state are re-read on every call — never cached — and either one forces the verdict to invalid at score 0, with a reason naming which. So a cached valid cannot outlive a suppression you added a second ago.

Cache behavior

  • The intrinsic result (syntax, disposable, role, MX) is cached in process memory per address, and MX answers are cached per domain (email_verify.cache_ttl, email_verify.mx_cache_ttl). The same address and domain are not re-checked on every call. On a multi-node deployment each node keeps its own cache.
  • The per-project flags (suppressed, previously_bounced) are always re-evaluated on each request and layered onto the (possibly cached) intrinsic result, so they reflect your current state even on a cache hit.
  • cached is true in the response when the intrinsic result came from the cache.
  • Pass ?fresh=true to bypass the cache and recompute the intrinsic result.

Response

{
    "verification": {
        "email": "support@acme-industrial.example",
        "status": "risky",
        "score": 60,
        "reason": "this looks like a role account rather than a person",
        "checks": {
            "syntax": true,
            "mx": true,
            "disposable": false,
            "role_account": true,
            "smtp": "skipped"
        },
        "mailbox_verified": false,
        "suppressed": false,
        "previously_bounced": false,
        "cached": true,
        "checked_at": "2026-05-31T12:00:00Z"
    }
}

A transient DNS failure is unknown, not invalid

If the domain’s servers cannot be resolved right now - a timeout or SERVFAIL rather than a definitive “no such domain” - the verdict is unknown with a score of 50, and the result is not cached. Treating a bad minute on your resolver as proof that an address is dead would suppress real customers, and caching it would make that stick for a day.

The verdict

status and score always agree, so branch on whichever suits your code:

statusscoreReached when
valid90Syntax is good, the domain takes mail, the local part is not a role
risky60The same, but the local part looks like a role rather than a person
unknown50The lookup itself failed. Not evidence of anything
disposable10The domain is a known throwaway provider
invalid0Bad syntax, no mail servers, suppressed, or hard-bounced for you

reason carries a sentence explaining anything that is not plainly valid, and is omitted when there is nothing to explain.

The rest of the body

email is the address as it was normalized — trimmed and lowercased — which is what the verdict applies to. checked_at is when the intrinsic result was computed, so on a cache hit it is older than the request. cached says which of the two you got.

suppressed and previously_bounced are your project’s own facts, always fresh. mailbox_verified is permanently false and exists precisely so nothing can read a valid verdict as proof a mailbox is there.

checks breaks out what was actually established: syntax, mx, disposable and role_account as booleans, plus smtp, which is the string "skipped" and always will be.

Refusals

StatusWhen
400email is missing or not a valid address
400Verification is disabled on this install — the message names email_verify.enabled

There is no rate limit of its own on this route. The MX cache is what keeps a loop over a large list from becoming a loop over your resolver, so leave fresh alone unless you have a reason.