What we'll build
Salesforce's Email field only checks the format, so leads arrive with made-up domains, disposable inboxes and typos like joe@gamil.com. By the end of this guide, every new or changed Lead email will be checked by the validateemail.io API, and the result stored on the Lead:
- A Named Credential holds your API key, so it never appears in code.
- A trigger and a Queueable job check addresses in the background, up to 100 per API call.
- A Flow action checks an address while a user is typing it into a screen flow and offers the typo fix.
- A batch job checks the leads you already have.
You'll need an org that runs Apex (Enterprise, Unlimited, Performance or Developer edition), a sandbox to build in, and a validateemail.io API key. The same pattern works for Contacts: copy the fields, trigger and job and change the object name.
1. Store the API key in a Named Credential
A Named Credential keeps the key out of your code and tells Salesforce the callout is allowed, so you don't need a Remote Site Setting. In Setup, open Named Credentials:
- On the External Credentials tab, create one with the label
validateemail.io, the nameValidateEmailand the authentication protocol Custom. - Add a principal, and give it an authentication parameter named
ApiKeywhose value is your API key. - Add a custom header named
Authorizationwith the value{!'Bearer ' & $Credential.ValidateEmail.ApiKey}. - On the Named Credentials tab, create one with the label
validateemail.io, the nameValidateEmail, the URLhttps://validateemail.ioand the external credential you just made. Make sure Allow Formulas in HTTP Header is ticked. - Open the permission set your users have (or create one), go to External Credential Principal Access and add the
ValidateEmailprincipal. Assign it to everyone who creates leads, including integration users.
The last step is easy to miss. Without it, the callout fails with an error about the external credential, even though the key is correct. Salesforce moves these screens around between releases, so if a label doesn't match, check Salesforce Help's article on named credentials.
2. Add fields to the Lead
In Object Manager, add four custom fields to Lead to hold the result:
| Field label | API name | Type |
|---|---|---|
| Email Status | Email_Status__c | Picklist with the values valid, invalid, risky and unknown |
| Email Reason | Email_Reason__c | Text (40) |
| Email Suggestion | Email_Suggestion__c | Text (255) |
| Email Checked | Email_Checked__c | Date/Time |
Add them to the Lead page layout and to a list view, so sales can filter out invalid leads before calling them.
3. Call the API from Apex
This class sends addresses to the batch endpoint, 100 at a time, through the Named Credential. The API returns results in the same order as the addresses you send, so the caller can match them up by position:
public with sharing class ValidateEmailService {
// The batch endpoint takes up to 100 addresses per request.
public static final Integer MAX_BATCH = 100;
public class Result {
public String email;
public String status; // valid, invalid, risky or unknown
public String reason;
public Decimal score;
public String did_you_mean;
}
public class ValidateEmailException extends Exception {}
public class BatchResponse {
public List<Result> results;
}
/** Validates any number of addresses. Results come back in the same order as the input. */
public static List<Result> validate(List<String> emails) {
List<Result> results = new List<Result>();
for (Integer start = 0; start < emails.size(); start += MAX_BATCH) {
List<String> chunk = new List<String>();
for (Integer i = start; i < Math.min(start + MAX_BATCH, emails.size()); i++) {
chunk.add(emails[i]);
}
results.addAll(callBatch(chunk));
}
return results;
}
private static List<Result> callBatch(List<String> emails) {
HttpRequest req = new HttpRequest();
// "ValidateEmail" is the Named Credential, which adds the API key header.
req.setEndpoint('callout:ValidateEmail/v1/validate/batch');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(20000);
req.setBody(JSON.serialize(new Map<String, Object>{ 'emails' => emails }));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
throw new ValidateEmailException('validateemail.io returned HTTP ' + res.getStatusCode() + ': ' + res.getBody());
}
return ((BatchResponse) JSON.deserialize(res.getBody(), BatchResponse.class)).results;
}
}The response includes more than this class reads, such as the result of each individual check. JSON.deserialize() ignores fields the class doesn't declare, so add any you want to store. The API reference lists them all.
4. Check new and changed leads automatically
Salesforce doesn't allow callouts from a trigger, so the trigger queues a job and the job makes the callout:
trigger LeadEmailValidation on Lead (after insert, after update) {
Set<Id> changed = new Set<Id>();
for (Lead newLead : Trigger.new) {
Lead oldLead = Trigger.isUpdate ? (Lead) Trigger.oldMap.get(newLead.Id) : null;
if (newLead.Email != null && (oldLead == null || newLead.Email != oldLead.Email)) {
changed.add(newLead.Id);
}
}
// Triggers can't make callouts, so the check runs in a Queueable job.
if (!changed.isEmpty() && Limits.getQueueableJobs() < Limits.getLimitQueueableJobs()) {
System.enqueueJob(new ValidateLeadEmailsJob(changed));
}
}public with sharing class ValidateLeadEmailsJob implements Queueable, Database.AllowsCallouts {
private final Set<Id> leadIds;
public ValidateLeadEmailsJob(Set<Id> leadIds) {
this.leadIds = leadIds;
}
public void execute(QueueableContext context) {
List<Lead> leads = [SELECT Id, Email FROM Lead WHERE Id IN :leadIds AND Email != null];
List<String> emails = new List<String>();
for (Lead l : leads) {
emails.add(l.Email);
}
List<ValidateEmailService.Result> results = ValidateEmailService.validate(emails);
List<Lead> updates = new List<Lead>();
for (Integer i = 0; i < leads.size(); i++) {
updates.add(new Lead(
Id = leads[i].Id,
Email_Status__c = results[i].status,
Email_Reason__c = results[i].reason,
Email_Suggestion__c = results[i].did_you_mean,
Email_Checked__c = System.now()
));
}
update updates;
}
}The trigger only queues leads whose email is new or has changed. When the job writes the result back, the email hasn't changed, so the trigger doesn't queue another job. A data import of 10,000 leads arrives in chunks of 200, so it queues 50 jobs, each making two callouts.
Decide what to do with the result in the tools your admins already use. A validation rule can't see the result in time, because the check finishes after the lead is saved. Instead, use a list view or report on Email Status, or a record-triggered flow that runs when the status changes, to alert the owner of a lead marked invalid or risky.
5. Suggest typo fixes in a screen flow
The best time to fix a typo is while the person who typed it is still there. This invocable action lets a screen flow check an address and show the suggestion:
public with sharing class ValidateEmailAction {
public class Request {
@InvocableVariable(label='Email address' required=true)
public String email;
}
public class Response {
@InvocableVariable(label='Status')
public String status;
@InvocableVariable(label='Reason')
public String reason;
@InvocableVariable(label='Did you mean')
public String didYouMean;
}
@InvocableMethod(label='Validate email address' description='Checks an email address with validateemail.io' callout=true)
public static List<Response> validate(List<Request> requests) {
List<String> emails = new List<String>();
for (Request r : requests) {
emails.add(r.email);
}
List<Response> responses = new List<Response>();
for (ValidateEmailService.Result result : ValidateEmailService.validate(emails)) {
Response out = new Response();
out.status = result.status;
out.reason = result.reason;
out.didYouMean = result.did_you_mean;
responses.add(out);
}
return responses;
}
}In Flow Builder, add an Action element after the screen that collects the email and choose Validate email address. Then add a Decision: if Did you mean isn't empty, show a screen that asks "Did you mean …?" and lets the user accept it. Give the action a fault path, so an API outage shows a message instead of an error.
Salesforce won't run a callout after the flow has changed records in the same transaction. Put the action before any Create Records or Update Records element, or set the action to always start a new transaction in its advanced settings.
6. Test it
Salesforce requires test coverage before you can deploy Apex to production. Tests can't make real callouts, so this one uses a mock that answers like the batch endpoint:
@IsTest
private class ValidateEmailTest {
// Answers like the real batch endpoint: "@gamil.com" is a typo, everything else is valid.
private class MockApi implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(req.getBody());
List<Object> results = new List<Object>();
for (Object item : (List<Object>) body.get('emails')) {
String email = (String) item;
Boolean typo = email.endsWith('@gamil.com');
results.add(new Map<String, Object>{
'email' => email,
'status' => typo ? 'risky' : 'valid',
'reason' => typo ? 'possible_typo' : 'mail_server_found',
'did_you_mean' => typo ? email.replace('@gamil.com', '@gmail.com') : null
});
}
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/json');
res.setBody(JSON.serialize(new Map<String, Object>{ 'results' => results }));
return res;
}
}
@IsTest
static void validatesNewLeads() {
Test.setMock(HttpCalloutMock.class, new MockApi());
Test.startTest();
insert new List<Lead>{
new Lead(LastName = 'Doe', Company = 'Acme', Email = 'jane@gmail.com'),
new Lead(LastName = 'Roe', Company = 'Acme', Email = 'joe@gamil.com')
};
Test.stopTest(); // runs the queued job
Map<String, Lead> byEmail = new Map<String, Lead>();
for (Lead l : [SELECT Email, Email_Status__c, Email_Suggestion__c FROM Lead]) {
byEmail.put(l.Email, l);
}
Assert.areEqual('valid', byEmail.get('jane@gmail.com').Email_Status__c);
Assert.areEqual('risky', byEmail.get('joe@gamil.com').Email_Status__c);
Assert.areEqual('joe@gmail.com', byEmail.get('joe@gamil.com').Email_Suggestion__c);
}
@IsTest
static void flowActionReturnsSuggestion() {
Test.setMock(HttpCalloutMock.class, new MockApi());
ValidateEmailAction.Request request = new ValidateEmailAction.Request();
request.email = 'joe@gamil.com';
Test.startTest();
List<ValidateEmailAction.Response> responses = ValidateEmailAction.validate(new List<ValidateEmailAction.Request>{ request });
Test.stopTest();
Assert.areEqual('possible_typo', responses[0].reason);
Assert.areEqual('joe@gmail.com', responses[0].didYouMean);
}
}Test.stopTest() runs the queued job, so the assertions see the stored result. If your org has validation or duplicate rules on Lead, add whatever fields they need to the test records.
7. Check the leads you already have
The trigger only handles new and changed emails. To check existing leads, run this batch class once:
public with sharing class ValidateExistingLeadsBatch implements Database.Batchable<SObject>, Database.AllowsCallouts {
public Database.QueryLocator start(Database.BatchableContext context) {
return Database.getQueryLocator([SELECT Id FROM Lead WHERE Email != null AND Email_Checked__c = null]);
}
public void execute(Database.BatchableContext context, List<Lead> scope) {
new ValidateLeadEmailsJob(new Map<Id, Lead>(scope).keySet()).execute(null);
}
public void finish(Database.BatchableContext context) {}
}Start it from the Developer Console's Execute Anonymous window. A scope of 100 means one API call per batch:
Database.executeBatch(new ValidateExistingLeadsBatch(), 100);Because it skips leads that already have an Email Checked date, you can stop and rerun it safely. At £0.002 per email, checking 50,000 existing leads costs £100.
Frequently asked questions
Does Salesforce validate email addresses?
Only the format. A standard Email field rejects values that don't look like an address, but it accepts made-up domains, disposable inboxes and typos like gamil.com. Checking those needs a call to an external service, which is what this guide sets up.
Do the callouts count against my Salesforce API limits?
No. Org API request limits count calls into Salesforce. Callouts from Apex count against per-transaction limits instead: up to 100 callouts per transaction. The code in this guide sends up to 100 addresses per callout.
Why does the trigger use a Queueable job?
Salesforce doesn't allow callouts from a trigger, because the record is still being saved. The trigger queues a job, and the job calls the API and writes the result back a few seconds later.
Do I need a managed package?
No. Everything here is a Named Credential, four custom fields and a few Apex classes that you deploy yourself, so you can change the policy to suit your org.
What does it cost to check my existing leads?
Pay as you go is £0.002 per email, so checking 50,000 existing leads costs £100. After that, only new leads and changed email addresses are checked.