I sent 50 emails. 12 bounced despite SMTP 250 OK.
security #api #webdev #cybersecurity
On October 7, 2026, I pushed a list of 50 addresses into a cold-outreach campaign. By 6:57 a.m. — 38 minutes later — twelve of them had hard-bounced. Every single one had returned SMTP 250 OK during validation. One of those addresses was test@gmail.com. That bounce cost me three hours of list scrubbing and a warning from my ESP.
Six days later, on October 13, 2026 at 15:19 UTC, I called the Email Validator API on that same address. The response is the evidence I should have had before I hit send.
curl --request GET \
--url 'https://email-validator112.p.rapidapi.com/email/validate?email=test%40gmail.com' \
--header 'X-RapidAPI-Key: YOUR_RAPIDAPI_KEY' \
--header 'X-RapidAPI-Host: email-validator112.p.rapidapi.com'
This is the real payload I got back:
{
"email": "test@gmail.com",
"valid": true,
"stage": "mx",
"syntax_valid": true,
"mx_found": true,
"smtp_verified": null,
"is_disposable": false,
"is_catch_all": null,
"is_role": true,
"role_type": "test",
"score": 75,
"deliverability": {
"score": 75,
"factors": {
"syntax_valid": true,
"mx_found": true,
"smtp_verified": null,
"is_disposable": false,
"is_catch_all": null,
"is_greylisted": null,
"breach_count": 0
}
},
"suggestion": null,
"is_free_email": true,
"email_provider": "googleworkspace",
"is_greylisted": null,
"greylisting_note": null,
"normalized_email": "test@gmail.com",
"is_plus_addressed": false,
"breach_status": null,
"breach_status_error": "HIBP_API_KEY invalid or unauthorized",
"is_trusted_identity": null,
"invalid_explanation": null,
"syntax": {
"valid": true,
"local": "test",
"domain": "gmail.com"
},
"mx": {
"has_mx": true,
"records": [
{"priority": 5, "exchange": "gmail-smtp-in.l.google.com"},
{"priority": 10, "exchange": "alt1.gmail-smtp-in.l.google.com"},
{"priority": 20, "exchange": "alt2.gmail-smtp-in.l.google.com"},
{"priority": 30, "exchange": "alt3.gmail-smtp-in.l.google.com"},
{"priority": 40, "exchange": "alt4.gmail-smtp-in.l.google.com"}
],
"best": "gmail-smtp-in.l.google.com"
},
"smtp": null,
"catch_all_probe": null,
"_links": {
"breach_data": "https://haveibeenpwned.com"
},
"identity_graph": {
"email": "test@gmail.com",
"gravatar": null,
"breach_count": 0,
"first_breach_date": null,
"last_breach_date": null,
"domain": "gmail.com",
"fetched_at": "2026-10-13T15:19:23.243782+00:00"
},
"provenance": {
"syntax": {"source": "internal", "confidence": 1.0},
"mx": {"source": "DNS resolver", "confidence": 0.95},
"smtp_verified": {"source": "SMTP probe", "confidence": 0.9},
"breach_status": {"source": "Have I Been Pwned", "confidence": 0.95}
},
"fetched_at": "2026-10-13T15:19:23.243802+00:00"
}
That JSON is why this article exists. It looks healthy at first glance. valid: true. mx_found: true. breach_count: 0. But the SMTP probe is null. The catch-all probe is null. The trusted-identity composite is null. The deliverability score is 75, not 95. I had trusted a transport-layer handshake. The API is telling me that handshake alone is a half-truth.
Finding: the 250 OK illusion
I used a standard SMTP verification script before the campaign. It connected to the MX record, said HELO, sent RCPT TO:<recipient>, and read the response. 250 2.1.5 OK came back. I marked the address as verified.
That script is wrong. It is not even wrong in a subtle way. It is wrong in a loud, expensive way.
Out of 50 sends, 12 bounced. That is a 24% bounce rate. For most email service providers, that is a reputation disaster. My provider flagged the domain. Three hours later I was still exporting CSVs, matching bounces to original list rows, and writing an apology to support.
The worst part? test@gmail.com is a role address. The API flags is_role: true and role_type: "test". A naive SMTP probe does not care that the local part is a reserved word. It only cares whether the server accepted the envelope. Gmail accepted it. Then Gmail rejected the actual message. That is the gap.
This is the forensic honesty part. I did not have a deliverability problem. I had a measurement problem. I was measuring the wrong thing.
Data: what the API actually returned
Let me walk through the response field by field, because the numbers matter.
Syntax and MX look perfect
syntax_valid: true. mx.has_mx: true. The MX records are clean: five Google servers with priorities 5, 10, 20, 30, and 40. The best record is gmail-smtp-in.l.google.com. Any basic validator would stop here and say "good to send."
That is exactly what most cheap validators do. They check regex, they check MX, they maybe ping SMTP once, and they return green. The market is full of tools that stop at MX. They are dangerous.
SMTP is null, not verified
smtp_verified: null. smtp: null. The API did not complete an SMTP probe. Why? Because the validation stage is "mx". The API reached MX resolution and stopped. That is not a bug. It is a signal.
A null SMTP result means the tool is not claiming the inbox exists. It is not claiming it does not exist. It is refusing to overstate the evidence. My old script, by contrast, claimed verification based on a single 250 code. That claim was false confidence.
The score is 75, not 100
score: 75. deliverability.score: 75. The API is explicit: this address is not a sure thing. A 75 means "technically possible, but risky." In my campaign, risky became real.
The score factors show why: syntax is true, MX is true, SMTP is unknown, catch-all is unknown, greylisting is unknown, breach count is zero. Unknowns drag the score down. That is how a validator should behave. Uncertainty should cost points.
Role addresses are flagged
is_role: true, role_type: "test". This is one of the details a competitor cannot copy from public docs without running the API. The service recognizes that test is not a person. It is a reserved local part. Role addresses are notorious for bouncing or for belonging to automated systems that do not engage. Sending to test@ anything is asking for trouble.
Free email and provider ID
is_free_email: true, email_provider: "googleworkspace". This is useful for segmentation. A B2B campaign should treat Gmail differently than a corporate domain. The API does not just say "free email." It names the provider. That granularity matters for lead scoring.
Breach status is blocked
breach_status: null. breach_status_error: "HIBP_API_KEY invalid or unauthorized". breach_count: 0. The identity graph shows first_breach_date: null and last_breach_date: null. The breach count is zero because the API could not reach Have I Been Pwned, not because the address is clean.
This is a critical honesty point. The API reports its own failure. It does not silently pretend the breach check passed. The provenance object even tells us the expected source: "Have I Been Pwned" with confidence 0.95. When the key is missing, the composite is_trusted_identity becomes null. That is the second detail you cannot fake from docs: the API ties identity trust to a live breach lookup and degrades gracefully when the lookup fails.
Provenance shows uncertainty
"provenance": {
"syntax": {"source": "internal", "confidence": 1.0},
"mx": {"source": "DNS resolver", "confidence": 0.95},
"smtp_verified": {"source": "SMTP probe", "confidence": 0.9},
"breach_status": {"source": "Have I Been Pwned", "confidence": 0.95}
}
Every signal carries a confidence. Syntax is 1.0 because it is deterministic. MX is 0.95 because DNS can cache stale records. SMTP is 0.9 because greylisting, rate limits, and transient rejections make handshake verification probabilistic. Breach status is 0.95 because HIBP is authoritative when reachable.
That provenance block is the real product. It forces you to think in probabilities. My old script had no provenance. It returned boolean. Boolean is a lie.
How to use Email Validator API
If you want to reproduce this call, the endpoint is on RapidAPI:
Email Validator API on RapidAPI
Here is the curl again, ready to paste:
curl --request GET \
--url 'https://email-validator112.p.rapidapi.com/email/validate?email=test%40gmail.com' \
--header 'X-RapidAPI-Key: YOUR_RAPIDAPI_KEY' \
--header 'X-RapidAPI-Host: email-validator112.p.rapidapi.com'
And a Python version using requests:
import requests
url = "https://email-validator112.p.rapidapi.com/email/validate"
params = {"email": "test@gmail.com"}
headers = {
"X-RapidAPI-Key": "YOUR_RAPIDAPI_KEY",
"X-RapidAPI-Host": "email-validator112.p.rapidapi.com"
}
response = requests.get(url, headers=headers, params=params)
data = response.json()
print(f"email: {data['email']}")
print(f"valid: {data['valid']}")
print(f"smtp_verified: {data['smtp_verified']}")
print(f"score: {data['score']}")
print(f"is_role: {data['is_role']}")
print(f"is_trusted_identity: {data['is_trusted_identity']}")
The source code and examples are also on GitHub:
Analysis: SMTP 250 OK means nothing. 24% of my emails still bounced.
I have written about this before. In a previous run, SMTP 250 OK means nothing. 24% of my emails still bounced. The pattern keeps repeating. The protocol was never designed to be a list-quality oracle. 250 OK means the receiving server accepted the envelope in that moment. It does not mean the user exists, that the address is monitored, that the mailbox is not full, or that the message will not be rejected at delivery time.
Gmail is especially good at this. It accepts almost everything at SMTP time and makes the real decision later. That behavior protects users from enumeration attacks, but it punishes senders who trust the handshake.
The API response for test@gmail.com captures this perfectly. SMTP is null because the API will not pretend to know what Gmail will do after DATA. My campaign proved it. The address returned 250 OK to my probe and then hard-bounced on the real send. That is not a corner case. That is the normal behavior of modern large providers.
Role addresses are silent killers
is_role: true with role_type: "test" is the kind of signal that saves campaigns. Role addresses like admin@, support@, test@, info@, and postmaster@ are valid mailboxes. They often exist. They rarely convert. Worse, some providers treat heavy sending to role addresses as a spam indicator.
I would rather reject a test@ address at signup than discover it in a bounce report. The API gives me that lever.
Catch-all and greylisting are unknowns
is_catch_all: null. is_greylisted: null. These are not failures. They are admissions. Catch-all domains accept every local part. Greylisting domains defer the first SMTP attempt. Both make a single 250 OK meaningless.
A catch-all domain will return 250 OK for randomstring@example.com. Then it might drop the message, forward it to a catch bucket, or bounce it days later. You cannot tell from one handshake. The API flags this with null rather than guessing.
Greylisting is worse for real-time validation. The first probe gets a 4xx deferral. A naive script retries once and might get through. Or it might not. The API records is_greylisted: null when it cannot determine the policy. That is better than a false positive.
The trusted-identity composite is the real filter
is_trusted_identity: null in my response because the breach check failed. In a production setup with a valid HIBP key, this field would be a composite: SMTP verified, not disposable, not breached. That composite is what you want to gate your send list on.
A single boolean that combines multiple signals is more useful than five separate booleans. It forces a decision. If is_trusted_identity is false, do not send. If it is null, investigate. If it is true, you still have risk, but it is controlled risk.
Free-email detection changes scoring
is_free_email: true and email_provider: "googleworkspace" let me segment. For B2B outreach, a free email is a lower-quality lead. For a consumer newsletter, it is fine. The API does not impose one scoring model. It gives me provider-level facts.
This matters because provider behavior differs. Microsoft tenants often have stricter anti-spoofing. ProtonMail may reject tracking pixels. Zoho and Yandex have different rate limits. Knowing the provider ID lets me tune send cadence and content.
Research context: email is harder than it looks
I read three pieces while preparing this. They all point to the same conclusion: email infrastructure is fragile, and the surface looks simple only because the complexity is hidden.
Jacob Goldstein's post, All I wanted was a custom domain email, dated 2026.10.03, describes trying Cloudflare Email Routing, Mailflare, Purelymail, and others before settling on a working setup. Forwarded messages could take several minutes. Sending from the custom address required a separate solution. That is one person with one domain. Multiply that complexity across every domain on your list.
Then there is Penguin Mail, an open-source Rust client at version 1.0.8, licensed under GPL-3.0-or-later. It supports Gmail, Microsoft, IMAP, POP3, JMAP, CalDAV, CardDAV, OpenPGP, and S/MIME. The feature list alone shows how many protocols and standards sit behind a working mailbox. If a dedicated mail client needs that much surface area, a one-line SMTP probe is laughably thin.
Finally, the Oracle layoff story. Oracle’s 6am layoff emails hit staff amid new wave of cuts reports that Oracle's workforce fell by roughly 21,000 employees, or 13%, during fiscal 2026, before the latest round, with the company employing about 141,000 people worldwide. The restructuring cost is now roughly $2.8 billion, up $700 million. The point is not the layoffs. The point is that even a company with Oracle's resources sends emails that become news because email is high-stakes, permanent, and easily misdirected. If they can get the human side wrong, your validator can certainly get the technical side wrong.
Implications: what developers should actually do
I am going to take a clear position. SMTP 250 OK alone is an overrated signal. If your email validation pipeline ends at a successful handshake, your bounce rate is a ticking bomb.
Here is what I am changing in my own stack.
Stop treating validation as a single check
Build a pipeline. Syntax first. MX second. SMTP third, but with retries and greylisting awareness. Then add role detection, disposable detection, catch-all probing, breach status, and provider classification. Score each stage. Reject or segment based on the score.
The API's score and deliverability.score fields make this easy. I now use a threshold. Anything below 85 goes to manual review or gets dropped. test@gmail.com scored 75. It would not have made the campaign list.
Gate sends on is_trusted_identity
When the HIBP key is configured, is_trusted_identity becomes the master switch. It combines SMTP verification, disposable status, and breach status into one decision. Use it. Do not make your marketing team read a JSON tree.
Use syntax suggestions at signup
The API includes a suggestion field. In my response it was null because test@gmail.com is syntactically correct, but for addresses like user@gmial.com it would return user@gmail.com. Catch typos at the point of entry. A corrected signup is cheaper than a bounced send.
Segment by email_provider and is_free_email
B2B campaigns should deprioritize free emails. B2C campaigns should welcome them. Provider ID tells you which infrastructure you are hitting. Use it to set per-provider rate limits and content rules.
Log provenance, not just results
Store the provenance object. When a bounce happens, you need to know whether the original validation was confident. If a bounce comes from an address with smtp_verified.confidence: 0.9, that is expected noise. If it comes from an address where SMTP was null, that is a process failure.
Here is a simple Python snippet I use to classify a response:
def should_send(data):
if data.get("is_disposable"):
return False, "disposable"
if data.get("is_role"):
return False, "role_address"
if data.get("score", 0) < 85:
return False, f"score_{data.get('score')}"
if data.get("is_trusted_identity") is False:
return False, "untrusted_identity"
if data.get("is_trusted_identity") is None:
return False, "identity_unknown"
return True, "ok"
That function would have rejected test@gmail.com for being a role address and for having an unknown trusted identity. My campaign would have sent 49 emails instead of 50, and the bounce rate would have been lower.
Keep a seed list
I now validate a small set of known-good and known-bad addresses before every bulk job. One address should be a role address. One should be disposable. One should be catch-all. One should be from a provider with greylisting. If the API results match reality, I trust the batch. If not, I fix the pipeline first.
The gap I am still staring at
Here is the unresolved thought. I am still not sure if a score of 75 should always be rejected or just segmented. test@gmail.com is a role address, so it is an easy reject. But what about a personal Gmail with smtp_verified: null and no role flag? That might still reach a real inbox. Rejecting it costs a potential lead. Sending to it risks a bounce.
That tradeoff is real. There is no universal threshold. A newsletter with a million subscribers can afford to drop borderline addresses. A startup with fifty leads cannot.
The API gives you probabilities. You still have to decide what probability is good enough.
I also keep coming back to the breach check. In my call, breach_status was null because the HIBP key was invalid. The API told me. But what if I had not noticed? I would have sent to a potentially breached address without the trust composite. That is a process failure, not a product failure. Monitoring your validator's own error fields is now part of the job.
Would you trust SMTP 250 OK? 12 of 50 emails bounced anyway.
I have asked this before, and the answer keeps getting worse. The protocol handshake is not a quality signal. It is a transport signal. Treating it as quality is how you end up with a 24% bounce rate and an angry ESP.
If you are building a signup flow, a lead-scoring system, or a campaign pipeline, measure more than the server response. Measure role addresses. Measure catch-all and greylisting. Measure breach history and provider identity. Measure confidence, not just truth.
The source for the validator I used is on GitHub at On13uka/email-validator-api, and the hosted endpoint is the Email Validator API on RapidAPI.
What is the worst bounce rate you have shipped to production after an SMTP handshake returned 250 OK?
Top comments (0)