How to Verify Email Addresses in PHP with CheckMail

Updated · April 2026

This 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:

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

§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