Validate an email
POST /v1/validate checks one email address and returns its status, the reason for it and a recommended action. It costs 1 credit, except when the result is unknown or the answer needs no mail-server check (malformed and test addresses), which are free.
Request
POST https://api.email-validation.org/v1/validate
Authorization: Bearer ev_live_…
Content-Type: application/json
{"email": "someone@example.com", "timeout": 10}| Field | Required | Description |
|---|---|---|
email | yes | The address to check (up to 1,024 characters). |
ip_address | no | IPv4 or IPv6 address the person used when they gave you the address, for example at sign-up. Ignored when it is not a valid IP. |
timeout | no | Seconds you are willing to wait, 5 to 15 (default 15). |
fresh | no | true to check the address again even when it was checked in the last 12 months (default false). See repeat checks. |
GET /v1/validate?email=… works too, with the same fields as query parameters. Prefer POST: it keeps addresses out of URLs, where proxies and logs may record them. Never put the API key in the URL.
Needs a key with the validate scope.
Response
{
"email": "catch_all@sandbox.email-validation.org",
"status": "risky",
"reason": "catch_all",
"action": "send_with_caution",
"account": "catch_all",
"domain": "sandbox.email-validation.org",
"did_you_mean": null,
"free_email": false,
"role_account": false,
"disposable": false,
"catch_all_domain": true,
"mx_found": true,
"mx_record": "mx.sandbox.email-validation.org",
"smtp_provider": "other",
"domain_age_days": 3650,
"processed_at": "2026-11-02T09:30:00.000Z",
"credits_used": 0,
"request_id": "req_7Hq2LmX9cV4bN8tR1zK6wD"
}| Field | Type | Description |
|---|---|---|
email | string | The address that was checked, in normalised form. |
status | Status | |
reason | Reason | |
action | Action | |
account | string or null | The part before the @. |
domain | string or null | The part after the @ (international domains in ASCII form). |
did_you_mean | string or null | A suggested correction when the address looks like a typo of a common domain. |
free_email | boolean or null | True when the domain is a free consumer mailbox service. |
role_account | boolean or null | True when the address belongs to a team or function (such as info@ or sales@) rather than a person. |
disposable | boolean or null | True when the address comes from a throwaway inbox service. |
catch_all_domain | boolean or null | True when the domain accepts mail for any address. |
mx_found | boolean or null | True when the domain publishes mail (MX) records. |
mx_record | string or null | The preferred mail server host for the domain. |
smtp_provider | SmtpProvider or null | |
domain_age_days | integer or null | Age of the domain registration in days, when known. |
processed_at | string (date-time) | When the check was made (UTC). A result reused from a check of the same address made in the last 12 months keeps that check's time. |
credits_used | integer: 0, 1 | 1 when this result cost a credit. Results with status unknown, answers to malformed addresses and sandbox addresses are free. |
request_id | string | Our id for this request. Quote it when you contact support. |
Fields always come in this order. A field is null when it does not apply or is not known; for example did_you_mean is set only for likely typos, and mx_record is null for domains without mail records.
Response headers:
| Header | Meaning |
|---|---|
X-Request-Id | Our id for the request, the same as request_id. |
X-Credits-Remaining | Your balance after this request. |
X-RateLimit-Limit | Requests per second allowed for this key or account. |
Statuses, reasons and actions
| Status | What it means | Default action | Credit |
|---|---|---|---|
deliverable | The mailbox exists and accepted our check, so mail sent to it should arrive. | send (send) | 1 |
undeliverable | Mail sent to this address will not arrive, so it should come off your list. | remove (remove) | 1 |
risky | The address may accept mail, but sending to it can hurt your bounce rate or sender reputation. | send_with_caution (send with caution) | 1 |
unknown | We could not get a definite answer this time, so no credit is used and a later retry often succeeds. | retry_later (retry later) | Free |
Each status has its own page with every reason explained: statuses and reasons.
Timeouts
Some mail servers answer slowly. We wait at most timeout seconds (default 15). When a check cannot finish in time, you get status: unknown with reason: timeout, and no credit is used. Set your HTTP client's timeout a little higher than timeout, for example 20 seconds.
In sign-up forms, a shorter timeout such as 5 or 6 seconds keeps the form fast; see the sign-up form guide.
Repeat checks
When an address was checked in the last 12 months, by you or anyone else, you may get the result of that check instead of a new one. It comes back at once and costs 1 credit, like a new check. processed_at always shows when the check was made, so you can tell how old a result is; domain_age_days is brought up to date.
Results with status unknown, catch-all results (catch_all, trusted_catch_all, role_catch_all, and any result with catch_all_domain: true) and mailbox_full are never reused: those addresses are always checked again. Answers to malformed and test addresses are never stored.
To check the address again anyway, send fresh=true: in the query string with GET, or with POST as a form field or JSON "fresh": true. A definite new result then replaces the stored one (an unknown result leaves it in place).
We do not keep the address for this: results are stored under a keyed hash of the address and deleted after 12 months. See the privacy policy.
Errors
| HTTP | Codes | Meaning |
|---|---|---|
| 400 | invalid_request | Missing or invalid field, or a key in the URL. |
| 401 | unauthorized | Missing, malformed or revoked key. |
| 402 | insufficient_credits | Not enough credits. |
| 403 | forbidden, forbidden_origin | Scope, account state or browser origin. |
| 429 | rate_limited, abuse_throttled, key_daily_cap | Slow down; see Retry-After. |
| 503 | service_unavailable | Temporarily unavailable; retry after Retry-After. |
No credit is used for a request that ends in an error. Details: Errors.
Examples
curl
# --retry also covers 429 and 503 and honours Retry-After (curl 7.66+); --retry-max-time stops it from waiting
# out a long Retry-After such as key_daily_cap's (until the next UTC day).
curl --fail-with-body --retry 3 --retry-max-time 30 https://api.email-validation.org/v1/validate \
-H "Authorization: Bearer $EV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "accepted@sandbox.email-validation.org"}'JavaScript
const API = 'https://api.email-validation.org/v1/validate';
const MAX_WAIT_S = 10; // never wait out a long Retry-After (key_daily_cap lasts until the next UTC day)
async function validate(email, attempts = 3) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(API, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EV_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(20_000),
});
if (res.ok) return res.json();
const { error } = await res.json();
const wait = Number(res.headers.get('Retry-After')) || 1;
const retryable =
(res.status === 429 || res.status === 503) && error.code !== 'key_daily_cap' && wait <= MAX_WAIT_S;
if (retryable && attempt < attempts) {
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
continue;
}
throw new Error(`${error.code}: ${error.message} (${error.request_id})`);
}
}
const result = await validate('accepted@sandbox.email-validation.org');
console.log(result.status, result.reason, result.action, result.credits_used);Python
import os
import time
import requests
API = "https://api.email-validation.org/v1/validate"
MAX_WAIT_S = 10 # never wait out a long Retry-After (key_daily_cap lasts until the next UTC day)
def validate(email: str, attempts: int = 3) -> dict:
for attempt in range(1, attempts + 1):
resp = requests.post(
API,
headers={"Authorization": f"Bearer {os.environ['EV_API_KEY']}"},
json={"email": email},
timeout=20,
)
if resp.ok:
return resp.json()
error = resp.json()["error"]
wait = int(resp.headers.get("Retry-After", "1"))
retryable = resp.status_code in (429, 503) and error["code"] != "key_daily_cap" and wait <= MAX_WAIT_S
if retryable and attempt < attempts:
time.sleep(wait)
continue
raise RuntimeError(f"{error['code']}: {error['message']} ({error['request_id']})")
raise RuntimeError("no attempts left")
result = validate("accepted@sandbox.email-validation.org")
print(result["status"], result["reason"], result["action"], result["credits_used"])PHP
<?php
function validate_email(string $email, int $attempts = 3): array
{
for ($attempt = 1; ; $attempt++) {
$headers = [];
$ch = curl_init('https://api.email-validation.org/v1/validate');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('EV_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return strlen($line);
},
]);
$raw = curl_exec($ch);
if ($raw === false) {
throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($raw, true);
if ($status === 200) {
return $body;
}
// Never wait out a long Retry-After: key_daily_cap lasts until the next UTC day.
$wait = max(1, (int) ($headers['retry-after'] ?? 1));
$code = $body['error']['code'] ?? '';
if (in_array($status, [429, 503], true) && $code !== 'key_daily_cap' && $wait <= 10 && $attempt < $attempts) {
sleep($wait);
continue;
}
throw new RuntimeException("{$body['error']['code']}: {$body['error']['message']}");
}
}
$result = validate_email('accepted@sandbox.email-validation.org');
echo "{$result['status']} {$result['reason']} {$result['action']}\n";Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
)
type Result struct {
Email string `json:"email"`
Status string `json:"status"`
Reason string `json:"reason"`
Action string `json:"action"`
CreditsUsed int `json:"credits_used"`
RequestID string `json:"request_id"`
}
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
} `json:"error"`
}
var client = &http.Client{Timeout: 20 * time.Second}
// maxWait: never wait out a long Retry-After (key_daily_cap lasts until the next UTC day).
const maxWait = 10
func validate(email string, attempts int) (*Result, error) {
payload, err := json.Marshal(map[string]string{"email": email})
if err != nil {
return nil, err
}
for attempt := 1; ; attempt++ {
req, err := http.NewRequest(http.MethodPost, "https://api.email-validation.org/v1/validate", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("EV_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusOK {
defer res.Body.Close()
var result Result
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
var e apiError
_ = json.NewDecoder(res.Body).Decode(&e)
res.Body.Close()
wait, err := strconv.Atoi(res.Header.Get("Retry-After"))
if err != nil || wait < 1 {
wait = 1
}
retryable := (res.StatusCode == http.StatusTooManyRequests || res.StatusCode == http.StatusServiceUnavailable) &&
e.Error.Code != "key_daily_cap" && wait <= maxWait
if retryable && attempt < attempts {
time.Sleep(time.Duration(wait) * time.Second)
continue
}
return nil, fmt.Errorf("%s: %s (%s)", e.Error.Code, e.Error.Message, e.Error.RequestID)
}
}
func main() {
result, err := validate("accepted@sandbox.email-validation.org", 3)
if err != nil {
log.Fatal(err)
}
fmt.Println(result.Status, result.Reason, result.Action, result.CreditsUsed)
}More languages, including TypeScript, Ruby, Java, C# and Rust: code samples.