Code samples
Copy-paste examples that validate one address, send the API key in the Authorization header, retry 429 and 503 answers after Retry-After when the wait is at most 10 seconds (never key_daily_cap, which lasts until the next UTC day), and report other errors with their code and request id. Each uses the free test address accepted@sandbox.email-validation.org; replace it with a real address when you are ready.
Set your key first: export EV_API_KEY="ev_live_…".
Validate an address
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);TypeScript
type Status = 'deliverable' | 'undeliverable' | 'risky' | 'unknown';
type Action = 'send' | 'send_with_caution' | 'remove' | 'retry_later';
interface ValidationResult {
email: string;
status: Status;
reason: string;
action: Action;
did_you_mean: string | null;
credits_used: 0 | 1;
request_id: string;
}
interface ApiError {
error: { code: string; message: string; request_id: string };
}
const MAX_WAIT_S = 10; // never wait out a long Retry-After (key_daily_cap lasts until the next UTC day)
export async function validate(email: string, attempts = 3): Promise<ValidationResult> {
for (let attempt = 1; ; attempt++) {
const res = await fetch('https://api.email-validation.org/v1/validate', {
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 (await res.json()) as ValidationResult;
const { error } = (await res.json()) as ApiError;
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})`);
}
}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";Ruby
require "json"
require "net/http"
require "uri"
API = URI("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, attempts: 3)
attempt = 0
loop do
attempt += 1
req = Net::HTTP::Post.new(API)
req["Authorization"] = "Bearer #{ENV.fetch("EV_API_KEY")}"
req["Content-Type"] = "application/json"
req.body = JSON.generate(email: email)
res = Net::HTTP.start(API.host, API.port, use_ssl: true, read_timeout: 20) { |http| http.request(req) }
return JSON.parse(res.body) if res.is_a?(Net::HTTPSuccess)
error = JSON.parse(res.body).fetch("error")
wait = [Integer(res["Retry-After"] || "1"), 1].max
if %w[429 503].include?(res.code) && error["code"] != "key_daily_cap" && wait <= MAX_WAIT_S && attempt < attempts
sleep(wait)
next
end
raise "#{error["code"]}: #{error["message"]} (#{error["request_id"]})"
end
end
result = validate("accepted@sandbox.email-validation.org")
puts [result["status"], result["reason"], result["action"]].join(" ")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)
}Java
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class ValidateEmail {
private static final HttpClient CLIENT =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
static String validate(String email, int attempts) throws IOException, InterruptedException {
// Build the JSON with your JSON library in real code; this address needs no escaping.
String json = "{\"email\":\"" + email + "\"}";
for (int attempt = 1; ; attempt++) {
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.email-validation.org/v1/validate"))
.timeout(Duration.ofSeconds(20))
.header("Authorization", "Bearer " + System.getenv("EV_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
int status = response.statusCode();
if (status == 200) {
return response.body();
}
// Never wait out a long Retry-After: key_daily_cap lasts until the next UTC day.
long wait = Math.max(1, response.headers().firstValueAsLong("Retry-After").orElse(1));
boolean dailyCap = response.body().contains("\"key_daily_cap\"");
if ((status == 429 || status == 503) && !dailyCap && wait <= 10 && attempt < attempts) {
Thread.sleep(wait * 1000);
continue;
}
throw new IOException("HTTP " + status + ": " + response.body());
}
}
public static void main(String[] args) throws Exception {
// The body is JSON: read status, reason, action and credits_used with Jackson, Gson or similar.
System.out.println(validate("accepted@sandbox.email-validation.org", 3));
}
}C#
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(20) };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("EV_API_KEY"));
var result = await Validate(http, "accepted@sandbox.email-validation.org");
Console.WriteLine($"{result.Status} {result.Reason} {result.Action} {result.CreditsUsed}");
static async Task<ValidationResult> Validate(HttpClient http, string email, int attempts = 3)
{
for (var attempt = 1; ; attempt++)
{
using var response = await http.PostAsJsonAsync("https://api.email-validation.org/v1/validate", new { email });
if (response.IsSuccessStatusCode)
return (await response.Content.ReadFromJsonAsync<ValidationResult>())!;
var body = await response.Content.ReadFromJsonAsync<ErrorEnvelope>();
// Never wait out a long Retry-After: key_daily_cap lasts until the next UTC day.
var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(1);
var retryable = (response.StatusCode is HttpStatusCode.TooManyRequests or HttpStatusCode.ServiceUnavailable)
&& body?.Error.Code != "key_daily_cap" && wait <= TimeSpan.FromSeconds(10);
if (retryable && attempt < attempts)
{
await Task.Delay(wait);
continue;
}
throw new HttpRequestException($"{body?.Error.Code}: {body?.Error.Message} ({body?.Error.RequestId})");
}
}
record ValidationResult(
[property: JsonPropertyName("email")] string Email,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("reason")] string Reason,
[property: JsonPropertyName("action")] string Action,
[property: JsonPropertyName("credits_used")] int CreditsUsed,
[property: JsonPropertyName("request_id")] string RequestId);
record ApiError(
[property: JsonPropertyName("code")] string Code,
[property: JsonPropertyName("message")] string Message,
[property: JsonPropertyName("request_id")] string RequestId);
record ErrorEnvelope([property: JsonPropertyName("error")] ApiError Error);Rust
// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }, serde = { version = "1", features = ["derive"] },
// serde_json = "1", tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
use std::time::Duration;
use reqwest::StatusCode;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ValidationResult {
email: String,
status: String,
reason: String,
action: String,
credits_used: u8,
}
#[derive(Debug, Deserialize)]
struct ApiError {
code: String,
message: String,
request_id: String,
}
#[derive(Debug, Deserialize)]
struct ErrorEnvelope {
error: ApiError,
}
async fn validate(
client: &reqwest::Client,
key: &str,
email: &str,
attempts: u32,
) -> Result<ValidationResult, Box<dyn std::error::Error>> {
let mut attempt = 0;
loop {
attempt += 1;
let res = client
.post("https://api.email-validation.org/v1/validate")
.bearer_auth(key)
.json(&serde_json::json!({ "email": email }))
.send()
.await?;
let status = res.status();
if status.is_success() {
return Ok(res.json().await?);
}
let wait = res
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(1)
.max(1);
let e = res.json::<ErrorEnvelope>().await?.error;
// Never wait out a long Retry-After: key_daily_cap lasts until the next UTC day.
let retryable = (status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE)
&& e.code != "key_daily_cap"
&& wait <= 10;
if retryable && attempt < attempts {
tokio::time::sleep(Duration::from_secs(wait)).await;
continue;
}
return Err(format!("{}: {} ({})", e.code, e.message, e.request_id).into());
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = std::env::var("EV_API_KEY")?;
let client = reqwest::Client::builder().timeout(Duration::from_secs(20)).build()?;
let r = validate(&client, &key, "accepted@sandbox.email-validation.org", 3).await?;
println!("{} {} {} {} {}", r.email, r.status, r.reason, r.action, r.credits_used);
Ok(())
}Read the credit balance
curl
curl https://api.email-validation.org/v1/credits -H "Authorization: Bearer $EV_API_KEY"JavaScript
const res = await fetch('https://api.email-validation.org/v1/credits', {
headers: { Authorization: `Bearer ${process.env.EV_API_KEY}` },
});
const credits = await res.json();
if (!res.ok) throw new Error(`${credits.error.code}: ${credits.error.message}`);
console.log(`Balance: ${credits.balance}`);Python
import os
import requests
resp = requests.get(
"https://api.email-validation.org/v1/credits",
headers={"Authorization": f"Bearer {os.environ['EV_API_KEY']}"},
timeout=10,
)
resp.raise_for_status()
credits = resp.json()
print("Balance:", credits["balance"])
for lot in credits["lots"]:
print(lot["remaining"], "credits expire on", lot["expires_at"][:10])Create a bulk job
curl
# A CSV file with a header row; the email column is found automatically.
curl https://api.email-validation.org/v1/bulk \
-H "Authorization: Bearer $EV_API_KEY" \
-H "Content-Type: text/csv" \
--data-binary @customers.csv
# Or a JSON list, without starting it yet:
curl "https://api.email-validation.org/v1/bulk?start=false" \
-H "Authorization: Bearer $EV_API_KEY" \
-H "Content-Type: application/json" \
-d '{"emails": ["accepted@sandbox.email-validation.org", "catch_all@sandbox.email-validation.org"]}'JavaScript
import { readFile } from 'node:fs/promises';
const csv = await readFile('customers.csv');
const res = await fetch('https://api.email-validation.org/v1/bulk?column=email&has_header=true', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EV_API_KEY}`,
'Content-Type': 'text/csv',
},
body: csv,
});
const job = await res.json();
if (!res.ok) throw new Error(`${job.error.code}: ${job.error.message}`);
console.log(job.id, job.status);Python
import os
import requests
with open("customers.csv", "rb") as f:
resp = requests.post(
"https://api.email-validation.org/v1/bulk",
params={"column": "email", "has_header": "true"},
headers={"Authorization": f"Bearer {os.environ['EV_API_KEY']}", "Content-Type": "text/csv"},
data=f.read(),
timeout=120,
)
job = resp.json()
if not resp.ok:
raise RuntimeError(f"{job['error']['code']}: {job['error']['message']}")
print(job["id"], job["status"])Verify a webhook
JavaScript
// Node.js (Express shown): use express.raw() so the body stays byte-for-byte as sent.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
function verifyWebhook(rawBody, headers, secret, toleranceSeconds = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers');
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) {
throw new Error('Timestamp outside tolerance');
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
for (const entry of signatures.split(' ')) {
const [version, sig] = entry.split(',');
if (version !== 'v1' || !sig) continue;
const given = Buffer.from(sig, 'base64');
if (given.length === expected.length && timingSafeEqual(given, expected)) return JSON.parse(rawBody);
}
throw new Error('No valid signature');
}
const app = express();
app.post('/webhooks/email-validation', express.raw({ type: 'application/json' }), (req, res) => {
let event;
try {
event = verifyWebhook(req.body, req.headers, process.env.EV_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
// Deduplicate on req.headers['webhook-id'], then handle event.type.
res.sendStatus(204);
});Python
import base64
import hashlib
import hmac
import json
import time
def verify_webhook(raw_body: bytes, headers, secret: str, tolerance: int = 300) -> dict:
"""headers: any case-insensitive mapping, e.g. request.headers in Flask or Django."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
raise ValueError("missing webhook headers")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
raise ValueError("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
for entry in signatures.split(" "):
version, _, sig = entry.partition(",")
if version != "v1" or not sig:
continue
try:
given = base64.b64decode(sig)
except ValueError:
continue
if hmac.compare_digest(given, expected):
return json.loads(raw_body)
raise ValueError("no valid signature")
# Flask: event = verify_webhook(request.get_data(), request.headers, os.environ["EV_WEBHOOK_SECRET"])PHP
<?php
function verify_webhook(string $rawBody, array $headers, string $secret, int $tolerance = 300): array
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signatures === '') {
throw new RuntimeException('Missing webhook headers');
}
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
throw new RuntimeException('Timestamp outside tolerance');
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
if ($key === false) {
throw new RuntimeException('Invalid webhook secret');
}
$expected = base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $sig] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $sig)) {
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}
}
throw new RuntimeException('No valid signature');
}
// Read the raw body before any framework parses it.
$event = verify_webhook(
file_get_contents('php://input'),
array_change_key_case(getallheaders(), CASE_LOWER),
getenv('EV_WEBHOOK_SECRET'),
);Go
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"math"
"net/http"
"strconv"
"strings"
"time"
)
// Verify checks a delivery. Read the body with io.ReadAll(r.Body) before calling it.
func Verify(body []byte, h http.Header, secret string, tolerance time.Duration) error {
id, ts, sigs := h.Get("webhook-id"), h.Get("webhook-timestamp"), h.Get("webhook-signature")
if id == "" || ts == "" || sigs == "" {
return errors.New("missing webhook headers")
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-sec)) > tolerance.Seconds() {
return errors.New("timestamp outside tolerance")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return err
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := mac.Sum(nil)
for _, entry := range strings.Split(sigs, " ") {
version, sig, ok := strings.Cut(entry, ",")
if !ok || version != "v1" {
continue
}
given, err := base64.StdEncoding.DecodeString(sig)
if err == nil && hmac.Equal(given, expected) {
return nil
}
}
return errors.New("no valid signature")
}More languages for webhook verification are on Webhooks.
Notes
- Use
POSTwith a JSON body; it keeps addresses out of URLs and logs. - Set the HTTP timeout to about 20 seconds: a check may take up to 15 seconds.
- A
200answer with the statusunknownis a normal, free result, not an error. - For JavaScript, TypeScript and Python, the official SDKs do all of this for you.