How to Verify Email Addresses in PHP with CheckMail
Updated · April 2026This guide shows you how to verify email addresses in PHP with the CheckMail API. You'll cover single and batch verification, error handling, retries with exponential backoff, and a complete reusable client class. No Composer packages required - everything runs on the cURL extension that ships with every PHP install.
PHP's built-in filter_var($email, FILTER_VALIDATE_EMAIL) only checks syntax. It happily accepts nonexistent@fake-domain-that-doesnt-exist.xyz. Real verification requires DNS lookups, SMTP handshakes, and disposable-domain detection, which is why most PHP apps outsource it to an API.
§01Prerequisites
You need PHP 7.4 or newer with the curl and json extensions enabled (both are on by default almost everywhere). Sign up for CheckMail and you'll get 100 free credits instantly. Keys look like cm_live_... for production and cm_test_... for sandboxing.
php --version # 7.4+
php -m | grep -i curl # should print "curl"
export CHECKMAIL_KEY=cm_live_xxxxxxxxxxxxxxxx
Read the key from getenv(), $_ENV, or a .env file loaded with vlucas/phpdotenv. Never commit it to git. Never echo it into a page.
§02Your first verification
A single verification is a GET to /v1/verify with the email as a query parameter and a bearer token in the Authorization header:
// verify.php
<?php
$key = getenv('CHECKMAIL_KEY');
$email = 'john@example.com';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.checkmail.dev/v1/verify?email=' . urlencode($email),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
CURLOPT_TIMEOUT => 15,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("CheckMail returned HTTP $status: $body");
}
$data = json_decode($body, true);
print_r($data);
Run php verify.php. You'll get back a PHP array describing the address. One credit is deducted per definitive result; unknown verdicts are free. Always set CURLOPT_TIMEOUT - without it, a stuck network call will block your entire request.
§03Understanding the response
Here's the full JSON shape of a successful response:
{
"email": "john@example.com",
"status": "valid",
"checks": {
"syntax": true,
"mx_found": true,
"smtp_valid": true,
"catch_all": false,
"disposable": false,
"role_based": false,
"free_provider": false
},
"suggestion": null,
"mx_host": "aspmx.l.google.com",
"cached": false,
"ms": 1243
}
The field you'll branch on 99% of the time is status:
valid- mailbox exists and will accept mail. Safe to send.invalid- bad syntax, no MX records, or the mail server explicitly rejected the address.catch_all- the domain accepts every address, so we can't tell for sure. Treat as lower confidence.disposable- a throwaway service like Mailinator. Usually reject at signup.unknown- temporary failure, greylisting, or upstream rate-limiting. Retry later. Not charged.
The suggestion field catches typos. Enter john@gnail.con and you'll get "john@gmail.com" back. The full schema is in the docs.
§04Batch verification
For verifying lists - newsletter imports, CRM cleanup, periodic hygiene jobs - use the batch endpoint. Up to 500 addresses in one request:
function verifyBatch(array $emails, string $key): array {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.checkmail.dev/v1/verify/batch',
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['emails' => $emails]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
],
CURLOPT_TIMEOUT => 60,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("Batch failed: $status $body");
}
return json_decode($body, true);
}
$payload = verifyBatch(
['alice@acme.com', 'bob@bouncy.test', 'carol@example.org'],
getenv('CHECKMAIL_KEY')
);
foreach ($payload['results'] as $r) {
echo "{$r['email']} → {$r['status']}\n";
}
echo "Charged: {$payload['charged']} credits\n";
Each result matches the single-verify schema. The charged field reports the real deduction - unknown results are free, so a batch of 500 can legitimately be billed at 487. Submitting more than 500 returns 400 batch_too_large, so chunk your list with array_chunk() in the caller. Raise the cURL timeout for batches since they take longer than a single request.
§05Error handling and retries
Client errors (4xx) are permanent - bad key, malformed request, quota exhausted - and retrying them won't help. Server errors (5xx) and rate limits (429) are transient. Retry with exponential backoff:
function verifyWithRetry(string $email, string $key, int $maxAttempts = 4): array {
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => 'https://api.checkmail.dev/v1/verify?email=' . urlencode($email),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
CURLOPT_TIMEOUT => 15,
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) {
return json_decode($body, true);
}
// Permanent - don't retry
if ($status >= 400 && $status < 500 && $status !== 429) {
throw new RuntimeException("Client error $status: $body");
}
// Transient - back off and retry
$delay = min(pow(2, $attempt - 1), 10);
sleep($delay);
}
throw new RuntimeException("Gave up after $maxAttempts attempts");
}
The backoff is 1s, 2s, 4s, 8s - capped at 10. If the call is running inside an HTTP request, be careful with sleep(): several seconds of blocking can time out your web request. For anything high-volume, push verification into a queued job (Laravel queues, Symfony Messenger, plain php artisan queue:work) instead of doing it inline.
§06Best practices
- Verify at signup. Check the address on form submission, server-side, before you persist the user record. Much cheaper than cleaning bad data later.
- Don't block the UI. A verification call averages 1–2 seconds. In a web request that hurts. Verify from a queue job, or accept the signup optimistically and mark it pending-verification.
- Cache results. We cache internally and mark
cached: truein the response, but still store the verdict in your own database. Don't re-verify the same address twice in one day. - Keep the key server-side. Never ship it to the browser, never echo it into a page, never log it.
- Handle
unknowngracefully. It means upstream was temporarily unreachable - not that the address is bad. Retry in 10 minutes from a queued job. - Use
suggestion. When it's set, show the user "Did you mean john@gmail.com?" - it recovers a surprising number of typos. - Reuse cURL handles. For scripts that verify many addresses, reuse one
$chbetween requests - it keeps the TLS connection warm and cuts latency.
§07Complete working example
A drop-in client class that handles batching, retries, and summarizing results. Save as CheckMailClient.php:
<?php
final class CheckMailClient {
private const BASE = 'https://api.checkmail.dev/v1';
private const BATCH_SIZE = 500;
public function __construct(private string $key) {}
public function verify(string $email): array {
return $this->request('GET', '/verify?email=' . urlencode($email));
}
public function verifyMany(array $emails): array {
$results = [];
$charged = 0;
foreach (array_chunk($emails, self::BATCH_SIZE) as $chunk) {
$page = $this->request('POST', '/verify/batch', ['emails' => $chunk]);
$results = array_merge($results, $page['results']);
$charged += $page['charged'];
}
return ['results' => $results, 'charged' => $charged];
}
private function request(string $method, string $path, ?array $body = null): array {
for ($attempt = 1; $attempt <= 4; $attempt++) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => self::BASE . $path,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->key,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) return json_decode($resp, true);
if ($status >= 400 && $status < 500 && $status !== 429) {
throw new RuntimeException("Client error $status: $resp");
}
sleep(min(pow(2, $attempt - 1), 10));
}
throw new RuntimeException('CheckMail request failed after retries');
}
}
// Usage
$client = new CheckMailClient(getenv('CHECKMAIL_KEY'));
$payload = $client->verifyMany([
'alice@acme.com',
'bob@example.org',
'typo@gnail.con',
'throwaway@mailinator.com',
]);
$summary = array_count_values(array_column($payload['results'], 'status'));
print_r($summary);
echo "Credits charged: {$payload['charged']}\n";
That's a production-ready verification client. Autoload it from Composer or drop it into a Laravel service, a Symfony bundle, or plain vanilla PHP.
§08Next steps
Grab an API key and start with 100 free credits. $2 per 1,000 after that. Credits never expire. For the full API reference - domain intelligence, account endpoints, auto-topup - see the docs.
Questions? mail@checkmail.dev