Salesforce lead email verification with a named credential and Apex
· 4 min read · Tutorials
To verify lead emails in Salesforce, store the verification API's URL and key in a named credential, have a Lead trigger enqueue a Queueable Apex job, and let that job call the API and write the result to custom fields like Email_Verification_Status__c. Reps and assignment rules then filter on the field instead of finding bad addresses through bounces. This tutorial uses pingutil's /v1/verify/batch, which checks up to 50 addresses per request without sending any email.
Why a named credential
Salesforce's Apex guide on named credentials as callout endpoints describes the idea: the named credential holds the endpoint URL and its authentication, and your code refers to it as callout:Name/path. The key stays out of Apex, and changing authentication doesn't mean changing code.
In current orgs the secret lives in an external credential and the URL in the named credential:
- External credential
Pingutil: authentication protocol Custom. Add a principal (named principal, so every user shares one key) with a parameter holding your pingutil API key. - On the external credential, add a custom header
Authorizationwhose value isBearerfollowed by a reference to that principal parameter. - Named credential
Pingutil: URLhttps://pingutil.com, linked to the external credential. - Grant the user the job runs as access to the principal through a permission set.
Fields to add on Lead
| Field | Type |
|---|---|
Email_Verification_Status__c |
Picklist: valid, invalid, risky, unknown |
Email_Verification_Reason__c |
Text(40) |
Email_Verification_Score__c |
Number(3, 0) |
The reason is worth keeping. risky / accept_all (catch-all domain) and risky / role_based (an info@ address) lead to different follow-up.
The trigger
Triggers can't make callouts in the save transaction, so this one only collects IDs and enqueues a job. It fires on insert and when the email changes, which also stops the job's own update from re-triggering it.
trigger LeadEmailVerify on Lead (after insert, after update) {
List<Id> ids = new List<Id>();
for (Lead l : Trigger.new) {
Lead old = Trigger.isUpdate ? Trigger.oldMap.get(l.Id) : null;
if (l.Email != null && (old == null || old.Email != l.Email)) {
ids.add(l.Id);
}
}
if (!ids.isEmpty()) {
System.enqueueJob(new LeadEmailVerifier(ids));
}
}
The Queueable job
Apex allows callouts from a Queueable class that implements Database.AllowsCallouts. The job below sends leads in chunks of 50 and updates them after all callouts are done, since Salesforce doesn't allow a callout after DML in the same transaction.
public class LeadEmailVerifier implements Queueable, Database.AllowsCallouts {
private final List<Id> leadIds;
public LeadEmailVerifier(List<Id> leadIds) {
this.leadIds = leadIds;
}
public void execute(QueueableContext ctx) {
List<Lead> leads = [SELECT Id, Email FROM Lead WHERE Id IN :leadIds AND Email != null];
List<Lead> updates = new List<Lead>();
for (Integer i = 0; i < leads.size(); i += 50) {
List<Lead> chunk = new List<Lead>();
List<String> emails = new List<String>();
for (Integer j = i; j < Math.min(i + 50, leads.size()); j++) {
chunk.add(leads[j]);
emails.add(leads[j].Email);
}
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Pingutil/v1/verify/batch');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setTimeout(30000);
req.setBody(JSON.serialize(new Map<String, Object>{ 'emails' => emails }));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() != 200) {
// 429 rate_limited / quota_exceeded or 5xx: leave these blank for a retry job.
continue;
}
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
List<Object> results = (List<Object>) body.get('results'); // same order as emails
for (Integer k = 0; k < results.size(); k++) {
Map<String, Object> r = (Map<String, Object>) results[k];
updates.add(new Lead(
Id = chunk[k].Id,
Email_Verification_Status__c = (String) r.get('status'),
Email_Verification_Reason__c = (String) r.get('reason'),
Email_Verification_Score__c = Decimal.valueOf(String.valueOf(r.get('score')))
));
}
}
if (!updates.isEmpty()) {
update updates;
}
}
}
The limits line up. Salesforce's callout limits allow 100 callouts per transaction, a default timeout of 10 seconds (we raise it to 30 because SMTP checks can be slow), and 120 seconds of total callout time. A 200-lead trigger chunk is four callouts. On the pingutil side, an account gets 60 requests a minute and 300 verifications a day; past the daily budget the API answers 429 quota_exceeded until 00:00 UTC. Leads that hit that come back blank, so a nightly scheduled job that picks up leads with an email and an empty status covers retries.
Prefer Flow?
Flow Builder can make HTTP callouts too. In a record-triggered flow, Salesforce's guidance is to make external calls from a Run Asynchronously path, which runs after the triggering record's transaction commits. It works with the same named credential. Apex is easier when you want batching and explicit retry handling.
What reps should do with each status
invalid: don't sequence. IfEmail_Verification_Reason__cismailbox_not_found, the address is wrong; look for a new one.riskywithaccept_all: the domain accepts everything, so the mailbox can't be confirmed. Contact in low volume. Background in catch-all email domains.unknown: greylisting, a block or a timeout. Re-check later; it isn't a verdict.valid: send, and keep bounce handling on, because SMTP checks can't promise delivery. See why SMTP verification is unreliable.
All response fields are in the verify API reference, and you can test addresses on the email verification page before writing any Apex. For the HubSpot version of this, see verifying emails in HubSpot.
Questions people ask
- Can a Salesforce trigger call an external API directly?
- Not in the same transaction as the save. The usual pattern is to have the trigger enqueue a Queueable class that implements Database.AllowsCallouts, which runs the callout asynchronously. Record-triggered flows have an equivalent: a Run Asynchronously path that runs after the triggering transaction commits.
- How many leads can one Apex job verify?
- Salesforce allows up to 100 callouts per Apex transaction and 120 seconds of cumulative callout time. With a batch endpoint that takes 50 addresses per request, a single Queueable job covers a full 200-record trigger chunk in four callouts.