The PHP reflex for email validation is filter_var($email, FILTER_VALIDATE_EMAIL) — a syntax check and nothing more. It is happy to bless diego@gmai.com: the format is fine, the domain is a typo. The other reflex is a heavyweight library promising to confirm the mailbox exists, a claim that depends on SMTP probing — out of scope here, permanently, because it needs sender-IP reputation and returns weak signals at catch-all providers.
The useful answer is an HTTP call that returns a structured verdict plus, when the domain was mistyped, the correction. This is the whole integration in PHP, using the cURL extension that ships enabled on effectively every PHP host.
Step 1: see the response shape with no account
POST /demo/check resolves a frozen fixture corpus — no key, no signup, rate-limited per client:
curl -X POST https://nobounce.dev/demo/check \
-H 'Content-Type: application/json' \
-d '{"email":"user@gmai.com"}'
{
"verdict": "undeliverable",
"reason": "typosquat_mx",
"suggestion": "user@gmail.com",
"confidence": 0.94,
"checked": { "syntax": true, "mx": true, "typo": true, "disposable": true },
"cached": false
}
It is fixture-only: every verdict and reason in the frozen taxonomy is reachable from it, so you can write your branching code against real shapes, but it cannot validate arbitrary real addresses. The full fixture list and the frozen enums are at GET /v1/config.
Step 2: get a key into the environment
There is no free tier. Live DNS checks require a paid key, entry $1/mo for 1,000 checks. You either redeem an operator-minted access code or pay self-serve through the card or stablecoin rail — both are a single POST /v1/keys call. The key is shown once; put it in an environment variable (or Laravel's .env) and never in a URL or a query parameter:
// .env / environment
// NOBOUNCE_KEY=...
Step 3: the check function
Two decisions matter more than the HTTP mechanics: a short timeout, and treating an unanswered call as no opinion rather than a rejection.
<?php
const CHECK_URL = 'https://nobounce.dev/v1/check';
function check_email(string $email): ?array
{
$handle = curl_init(CHECK_URL);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['email' => $email]),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . getenv('NOBOUNCE_KEY'),
],
CURLOPT_TIMEOUT_MS => 2000,
CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($handle);
$errno = curl_errno($handle);
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
if ($errno !== 0 || $body === false) {
return null; // timeout, DNS failure, transport — no opinion
}
$result = json_decode($body, true);
if (!is_array($result)) {
return null; // malformed upstream response — no opinion
}
if ($status >= 400) {
// RFC 9457 problem+json: the fix field says what to do.
error_log((string) ($result['fix'] ?? 'check failed'));
return null;
}
return $result;
}
Every non-2xx body carries a fix field in plain language — missing key, exhausted quota (429), malformed request — so log it instead of retrying the identical call blindly. With CURLOPT_RETURNTRANSFER set and CURLOPT_FAILONERROR left off, cURL hands you the 4xx body to read rather than swallowing it.
Step 4: branch on all four verdicts
Four verdicts, and only one of them stops anything:
$result = check_email('diego@gmai.com');
if ($result === null) {
accept(mxChecked: false); // outage ≠ bad address
} elseif ($result['verdict'] === 'undeliverable' && isset($result['suggestion'])) {
offerCorrection($result['suggestion'], $result['confidence']);
} elseif ($result['verdict'] === 'undeliverable') {
reject($result['reason']);
} elseif ($result['verdict'] === 'unknown') {
accept(mxChecked: $result['checked']['mx']); // DNS did not complete
} else {
accept(mxChecked: $result['checked']['mx']); // deliverable, risky
}
| verdict | meaning | your code |
|---|---|---|
deliverable |
syntax and MX both passed | accept |
risky |
deliverable but disposable or a role account (reason says which) |
your policy decision |
unknown |
the check did not complete; checked.mx is false |
accept and annotate, never reject |
undeliverable |
will bounce; reason is the cause |
reject, or offer suggestion |
Two branches deserve emphasis. A suggestion is not an error — undeliverable with a suggestion means you know which address the user meant to type, and offering it with one-click accept (plus a real "keep what I typed" escape hatch, since confidence is a probability) recovers a signup that a plain rejection loses. And unknown proceeds: an API timeout or an upstream DNS failure must never become a hard signup failure — the outage math behind that rule is in Fail Open: Email Validation Must Never Block a Signup.
Where filter_var still fits
Keep it, but only as a free local gate. filter_var costs nothing and catches the worst garbage — a missing @, spaces, an empty string — before you spend a paid check on it. It is not the syntax authority: the engine applies RFC 5322 as practised on its first layer, and filter_var disagrees with it at the edges. Reject locally what filter_var rejects, then let the API be the judge of everything else — domain existence, MX, typos, disposable providers.
Laravel
The same call with the HTTP client facade: Http::timeout(2)->withToken(config('services.nobounce.key'))->post(...) — identical headers, identical verdict handling. Put the function behind a small service class and call it from your registration controller, server-side. The API key must never reach the browser, which also means calling /v1/check from a backend route, not from JavaScript.
Lists, and what happens to the address
For one address per signup, the function above is complete. For a file of thousands, switch to POST /v1/check/batch — up to 1,000 addresses per call, results keyed by email_sha256, so responses are safe to log — the chunking loop and hash-join are covered in How to Batch-Validate a List of Email Addresses.
No plaintext address is stored anywhere in the service — anything persisted is SHA-256, domains in clear. If you later learn an address bounced, report it as a hash:
$digest = hash('sha256', 'user@gmail.com');
POST /v1/feedback accepts only that digest with the event; send a raw address and it returns a 400 rather than hashing it for you. POST /v1/hash computes the same digest server-side, in memory, if you need the canonical normalisation applied first.
Cache hits still count against the monthly quota — cached: true means the shared domain cache was warm, not that the check was skipped billing-wise. GET /v1/me reports usage and remaining quota, and why the suggestion engine runs before the MX lookup is covered in Suggest the Correction Instead of Rejecting the Signup.
The full ordered integration procedure, written for an agent to execute, is at /integrate.md.