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}
FieldRequiredDescription
emailyesThe address to check (up to 1,024 characters).
ip_addressnoIPv4 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.
timeoutnoSeconds you are willing to wait, 5 to 15 (default 15).
freshnotrue 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"
}
FieldTypeDescription
emailstringThe address that was checked, in normalised form.
statusStatus
reasonReason
actionAction
accountstring or nullThe part before the @.
domainstring or nullThe part after the @ (international domains in ASCII form).
did_you_meanstring or nullA suggested correction when the address looks like a typo of a common domain.
free_emailboolean or nullTrue when the domain is a free consumer mailbox service.
role_accountboolean or nullTrue when the address belongs to a team or function (such as info@ or sales@) rather than a person.
disposableboolean or nullTrue when the address comes from a throwaway inbox service.
catch_all_domainboolean or nullTrue when the domain accepts mail for any address.
mx_foundboolean or nullTrue when the domain publishes mail (MX) records.
mx_recordstring or nullThe preferred mail server host for the domain.
smtp_providerSmtpProvider or null
domain_age_daysinteger or nullAge of the domain registration in days, when known.
processed_atstring (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_usedinteger: 0, 11 when this result cost a credit. Results with status unknown, answers to malformed addresses and sandbox addresses are free.
request_idstringOur 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:

HeaderMeaning
X-Request-IdOur id for the request, the same as request_id.
X-Credits-RemainingYour balance after this request.
X-RateLimit-LimitRequests per second allowed for this key or account.

Statuses, reasons and actions

StatusWhat it meansDefault actionCredit
deliverableThe mailbox exists and accepted our check, so mail sent to it should arrive.send (send)1
undeliverableMail sent to this address will not arrive, so it should come off your list.remove (remove)1
riskyThe address may accept mail, but sending to it can hurt your bounce rate or sender reputation.send_with_caution (send with caution)1
unknownWe 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

HTTPCodesMeaning
400invalid_requestMissing or invalid field, or a key in the URL.
401unauthorizedMissing, malformed or revoked key.
402insufficient_creditsNot enough credits.
403forbidden, forbidden_originScope, account state or browser origin.
429rate_limited, abuse_throttled, key_daily_capSlow down; see Retry-After.
503service_unavailableTemporarily 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.

Updated · Maintained by the email-validation.org team