Lead Enrichment Automation with Salesforce Agentforce & Apollo.io
An end-to-end automated lead enrichment pipeline built entirely on the Salesforce platform, from trigger to AI-generated outreach recommendation, with no human intervention required.
Author
Richard Tuharsky
Date
March 22, 2026
Difficulty
Intermediate
Build Time
5 hours
Abstract
I built an automated lead enrichment pipeline entirely on the Salesforce platform. When a new Lead record is created, the system asynchronously calls the Apollo.io enrichment API, writes firmographic and contact data back to the record, scores the lead against ideal-customer-profile (ICP) criteria, and invokes an Agentforce AI agent that reasons over the enriched data and posts a personalised outreach recommendation, all without human intervention. This document covers the architecture, full Apex source code, Agentforce configuration, error handling approach, and the decisions I made along the way.
1. Problem Statement & Business Value
Sales Development Representatives (SDRs) and Account Executives waste an estimated 30–40% of their prospecting time manually researching lead context - company size, industry, job title, technology stack - before they can craft a personalised first touch. This is not only inefficient; it introduces inconsistency and delays the critical first-contact window where response rates are highest.
I built this project to eliminate that manual research cycle entirely. The moment a Lead record is created in Salesforce, whether via a web-to-lead form, a CSV import, or manual entry, the system automatically enriches it with firmographic and contact intelligence from Apollo.io, scores it against ICP criteria, and produces an AI-generated outreach recommendation via Agentforce. The rep opens the record to context, not blank fields.
Key Business Outcomes
| Outcome | Detail |
|---|---|
| Time saved per rep | Est. 45–60 min/day of manual research eliminated |
| Lead response speed | Enriched data available within seconds of record creation |
| ICP consistency | Scoring applied identically to every single lead |
| Rep experience | SDRs open records to context, not blank fields |
| Pipeline quality | Hot leads surfaced immediately for fast follow-up |
2. Solution Architecture
Data & Control Flow
| Step | Component | What Happens |
|---|---|---|
| 1 | Lead Created | Via web form, CSV import, or manual entry in Sales Cloud |
| 2 | Apex Trigger Fires | After-insert trigger enqueues an async Queueable job |
| 3 | Queueable Job Runs | Moves execution to async context, enabling HTTP callouts |
| 4 | Apollo.io API Call | HTTP GET /v1/people/match?email= returns enrichment JSON |
| 5 | Data Written Back | Parser maps JSON fields to Lead standard and custom fields |
| 6 | Scoring Executes | Apex scoring method calculates 0–100 ICP score |
| 7 | Agentforce Invoked | Agent reads score and enriched fields, generates recommendation |
| 8 | Chatter Post Created | AI recommendation posted to the Lead record Chatter feed |
| 9 | Log Written | Every call outcome stored in Lead_Enrichment_Log__c |
Component Inventory
LeadEnrichmentTrigger.trigger
Thin after-insert trigger, filters leads with email, delegates to Queueable
LeadEnrichmentQueueable.cls
Async callout class (implements Queueable, Database.AllowsCallouts)
ApolloEnrichmentParser.cls
JSON deserialisation and field mapping onto Lead
LeadScoringService.cls
ICP scoring logic, returns Integer 0–100
LeadEnrichmentService.cls
Orchestrator called by Queueable, parse, score, log
AgentforceRecommendationAction.cls
@InvocableMethod exposed to the Agentforce agent
Lead_Enrichment_Log__c
Custom object for observability, one record per enrichment attempt
Named Credential: Apollo_NC
Secure API key storage, no hardcoded credentials in Apex
Agentforce Agent: Lead Enrichment Advisor
AI reasoning layer, reads enriched data, posts Chatter recommendation
3. How I Built It
3.1 Custom Fields on the Lead Object
I started by creating these custom fields on the Lead object before writing any Apex. They receive the enriched data from the parser and store the ICP scoring output.
| API Name | Field Type | Purpose |
|---|---|---|
| LinkedInUrl__c | URL | LinkedIn profile URL from Apollo |
| TechStack__c | Long Text Area | Comma-separated technologies used (from Apollo) |
| EnrichmentStatus__c | Picklist | Pending / Enriched / Failed / Skipped |
| IcpScore__c | Number(3,0) | Calculated ICP score 0–100 |
| IcpTier__c | Picklist | Cold / Warm / Hot / Immediate Action |
| EnrichmentLastRun__c | DateTime | Timestamp of last enrichment call |
| ApolloOrganizationId__c | Text(50) | Apollo org ID for deduplication |
Lead_Enrichment_Log__c, Custom Object
I designed this as the black-box recorder for every enrichment attempt. Each time the Queueable fires and calls Apollo, it writes one record here documenting what happened, success, error, timeout, parse crash. Without it, diagnosing bulk failures means manually checking every Lead. With a simple list view filtered to Status != Success, failures surface instantly with the exact API response for debugging.
| Field Label | API Name | Field Type | Purpose |
|---|---|---|---|
| Lead | Lead__c | Lookup(Lead) | Links the log to the Lead record |
| Status | Status__c | Picklist | Success / API Error / Timeout / Parse Error |
| HTTP Status Code | HTTP_Status_Code__c | Number(3,0) | Stores 200, 429, 500, etc. |
| Response Body | Response_Body__c | Long Text Area(32768) | Raw API response for debugging |
| Error Message | Error_Message__c | Text Area(255) | Human-readable error summary |
| Retry Count | Retry_Count__c | Number(2,0) | How many times this lead was retried |
| Timestamp | Timestamp__c | DateTime | When the enrichment call happened |
3.2 ApolloEnrichmentParser
When the Queueable calls Apollo.io, it gets back a JSON response. This class takes that raw JSON string, deserialises it into typed Apex objects, and maps the values onto Lead fields. I used typed inner classes rather than an untyped Map for compile-time safety and cleaner code. When Apollo returns {"person": {"title": "VP Sales"}}, Salesforce automatically wires up the typed properties.
Why every field has a null check: Apollo does not guarantee every field will be present. A lead with only an email might return a person object but no organisation, or the organisation might exist but have no revenue data. I added null checks everywhere as defensive programming against Apollo's variable responses.
ApolloEnrichmentParser.cls
public class ApolloEnrichmentParser {
public class ApolloResponse {
public PersonData person;
public OrgData organization;
}
public class PersonData {
public String title;
public String linkedin_url;
}
public class OrgData {
public String name;
public String industry;
public Integer num_employees;
public Decimal annual_revenue;
public String country;
public String id;
public List<String> technologies;
}
// Static utility method: takes input, returns output, holds no state.
public static Lead parseToLead(String jsonBody, Id leadId) {
ApolloResponse resp = (ApolloResponse)
JSON.deserialize(jsonBody, ApolloResponse.class);
Lead l = new Lead(Id = leadId);
if (resp.person != null) {
if (resp.person.title != null)
l.Title = resp.person.title;
if (resp.person.linkedin_url != null)
l.LinkedIn_URL__c = resp.person.linkedin_url;
}
if (resp.organization != null) {
OrgData o = resp.organization;
if (o.name != null) l.Company = o.name;
if (o.industry != null) l.Industry = o.industry;
if (o.country != null) l.Country = o.country;
if (o.id != null) l.Apollo_Organization_Id__c = o.id;
if (o.num_employees != null) l.NumberOfEmployees = o.num_employees;
if (o.annual_revenue != null) l.AnnualRevenue = o.annual_revenue;
if (o.technologies != null)
l.Tech_Stack__c = String.join(o.technologies, ', ');
}
l.Enrichment_Status__c = 'Enriched';
l.Enrichment_Last_Run__c = System.now();
return l;
}
}3.3 LeadScoringService
I wrote this class to take an enriched Lead and calculate a score from 0 to 100 based on how well it matches the ideal customer profile, then assign a tier label. I hard-coded the ICP values here for readability, in production I'd move them to Custom Metadata Types so an admin can update them without a code deployment.
Why Set<String> instead of a List: A Set performs a hash lookup in O(1) constant time regardless of size. A List would scan each element linearly, fine for five items, but slower as the ICP criteria list grows.
Scoring Matrix
| Criteria | Points | Rationale |
|---|---|---|
| Industry matches ICP | +20 | Confirms vertical fit with target market |
| Employees > 200 | +15 | Signals sufficient budget and deal complexity |
| Business email domain | +10 | Filters out consumer / non-business leads |
| Personal email domain | −10 | Weaker signal, may still be a real buyer |
| Title is Dir / VP / C-Suite | +20 | Indicates economic buyer or internal champion |
| Country in target market | +15 | Confirms geographic addressability |
| Annual Revenue > $10M | +20 | Confirms deal size is viable |
| Employees < 20 | −10 | Active negative signal, not just absence of positive |
| Disqualifying title (intern, student) | 0 (hard cap) | No amount of positive signals overrides this |
LeadScoringService.cls
public class LeadScoringService {
private static final Set<String> ICP_INDUSTRIES = new Set<String>{
'Technology', 'Financial Services', 'Healthcare',
'Manufacturing', 'Retail'};
private static final Set<String> ICP_COUNTRIES = new Set<String>{
'United Arab Emirates', 'Saudi Arabia', 'United States',
'Canada', 'United Kingdom', 'Germany', 'France',
'Netherlands', 'Switzerland', 'Australia', 'Japan', 'Singapore'};
private static final Set<String> SENIOR_TITLES = new Set<String>{
'ceo', 'cto', 'cfo', 'vp', 'vice president',
'director', 'head of', 'chief'};
private static final Set<String> PERSONAL_DOMAINS = new Set<String>{
'gmail.com', 'yahoo.com', 'hotmail.com',
'outlook.com', 'icloud.com'};
// Hard disqualifiers, no positive signals can override these.
// A student at Google with a business email is still not a buyer.
private static final Set<String> DISQUALIFY_TITLES = new Set<String>{
'intern', 'student', 'trainee'};
public static Integer calculateScore(Lead l) {
Integer score = 0;
if (l.Title != null && isDisqualifiedTitle(l.Title)) return 0;
if (l.Industry != null && ICP_INDUSTRIES.contains(l.Industry))
score += 20;
if (l.NumberOfEmployees != null && l.NumberOfEmployees >= 200)
score += 15;
if (l.Title != null && isSeniorTitle(l.Title))
score += 20;
if (l.Country != null && ICP_COUNTRIES.contains(l.Country))
score += 15;
if (l.AnnualRevenue != null && l.AnnualRevenue >= 10000000)
score += 20;
// Email: business domain earns points, personal domain loses them.
if (l.Email != null && isPersonalEmail(l.Email)) {
score -= 10;
} else if (l.Email != null) {
score += 10;
}
// Confirmed micro-company is an active negative signal.
if (l.NumberOfEmployees != null && l.NumberOfEmployees < 20)
score -= 10;
// Clamp to 0-100. Penalties could theoretically push below 0.
return Math.min(100, Math.max(0, score));
}
public static String getTier(Integer score) {
if (score >= 80) return 'Immediate Action';
if (score >= 60) return 'Hot';
if (score >= 40) return 'Warm';
return 'Cold';
}
private static Boolean isPersonalEmail(String email) {
return PERSONAL_DOMAINS.contains(
email.substringAfter('@').toLowerCase());
}
private static Boolean isSeniorTitle(String title) {
String t = title.toLowerCase();
for (String kw : SENIOR_TITLES) {
if (t.contains(kw)) return true;
}
return false;
}
private static Boolean isDisqualifiedTitle(String title) {
String t = title.toLowerCase();
for (String kw : DISQUALIFY_TITLES) {
if (t.contains(kw)) return true;
}
return false;
}
}3.4 LeadEnrichmentService
This is the orchestrator. The Queueable calls it with the HTTP response, and it decides what happens next: parse the data, score the lead, update the record, and log the outcome. I handled three paths based on HTTP status code:
- 200, Parse, score, tier, update Lead, log Success
- 429, Rate-limited by Apollo; re-enqueue the job and log the reason
- Any other, Mark Lead as Failed, log the HTTP status and body
LeadEnrichmentService.cls
public class LeadEnrichmentService {
public static void handleResponse(
Id leadId, Integer statusCode, String body) {
Lead_Enrichment_Log__c log = new Lead_Enrichment_Log__c(
Lead__c = leadId,
HTTP_Status_Code__c = statusCode,
Timestamp__c = System.now()
);
if (statusCode == 200) {
Lead enriched = ApolloEnrichmentParser.parseToLead(body, leadId);
enriched.ICP_Score__c = LeadScoringService.calculateScore(enriched);
enriched.ICP_Tier__c = LeadScoringService.getTier(
(Integer) enriched.ICP_Score__c);
update enriched;
log.Status__c = 'Success';
} else if (statusCode == 429) {
// HTTP 429 = Too Many Requests (Apollo rate limit).
// Re-enqueue rather than marking as failed.
log.Status__c = 'API Error';
log.Error_Message__c = 'Rate limited. Re-queued.';
System.enqueueJob(
new LeadEnrichmentQueueable(new List<Id>{ leadId }));
} else {
log.Status__c = 'API Error';
log.Error_Message__c =
'HTTP ' + statusCode + ': ' + body.left(500);
update new Lead(
Id = leadId,
Enrichment_Status__c = 'Failed');
}
insert log;
}
}3.5 LeadEnrichmentQueueable
I chose to process one lead per job to stay safely within Salesforce governor limits. Each chained job gets its own fresh transaction with clean limits, 100 SOQL queries, 150 DML statements, 10 callouts. Processing one at a time was the right call here.
implements Queueable
Tells Salesforce this class can be enqueued via System.enqueueJob().
Database.AllowsCallouts
Marker interface that permits HTTP callouts inside this async context. Without it, the job would throw a CalloutException even though it runs asynchronously.
Why IDs, not sObjects?
By the time the Queueable executes, the trigger context is long gone. Any Lead sObject references from Trigger.new would be stale. A fresh SOQL query inside execute() is the correct approach.
LeadEnrichmentQueueable.cls
public class LeadEnrichmentQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> leadIds;
public LeadEnrichmentQueueable(List<Id> leadIds) {
this.leadIds = leadIds;
}
public void execute(QueueableContext ctx) {
if (leadIds.isEmpty()) return;
Id currentLeadId = leadIds.remove(0);
try {
Lead l = [SELECT Id, Email FROM Lead
WHERE Id = :currentLeadId LIMIT 1];
String apiKey = Apollo_Config__c.getOrgDefaults().API_Key__c;
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Apollo_NC/v1/people/match?email='
+ EncodingUtil.urlEncode(l.Email, 'UTF-8'));
req.setMethod('GET');
req.setHeader('Content-Type', 'application/json');
req.setHeader('X-Api-Key', apiKey);
req.setTimeout(10000); // 10 seconds
HttpResponse res = new Http().send(req);
LeadEnrichmentService.handleResponse(
currentLeadId, res.getStatusCode(), res.getBody());
} catch (Exception e) {
// Catch-all: query failures, network issues, unexpected exceptions.
// We still write a log so nothing fails silently.
insert new Lead_Enrichment_Log__c(
Lead__c = currentLeadId,
Status__c = 'API Error',
Error_Message__c = e.getMessage().left(255),
Timestamp__c = System.now()
);
update new Lead(
Id = currentLeadId,
Enrichment_Status__c = 'Failed');
}
// Chain: each chained job gets fresh governor limits.
if (!leadIds.isEmpty()) {
System.enqueueJob(new LeadEnrichmentQueueable(leadIds));
}
}
}3.6 LeadEnrichmentTrigger
I kept the trigger intentionally thin, zero business logic in the body, just filtering and delegation. All the logic lives in service classes where it's testable and reusable.
Why after insert, not before insert? The record must already have an ID to pass to the Queueable. An after-insert trigger also avoids modifying the record in-flight, keeping the trigger simple.
The Enrichment_Status__c != 'Enriched' guard prevents re-enriching leads that have already been processed, for example, if a rep edits a field on an already-enriched Lead.
LeadEnrichmentTrigger.trigger
trigger LeadEnrichmentTrigger on Lead (after insert) {
List<Id> toEnrich = new List<Id>();
for (Lead l : Trigger.new) {
if (String.isNotBlank(l.Email)
&& l.Enrichment_Status__c != 'Enriched') {
toEnrich.add(l.Id);
}
}
if (!toEnrich.isEmpty()) {
System.enqueueJob(new LeadEnrichmentQueueable(toEnrich));
}
}3.7 Agentforce Configuration
I configured Agentforce via Agent Builder in Setup, no code needed for the agent itself. The Apex class exposes an @InvocableMethod that the agent calls as a custom action. I went with a structured context string rather than raw JSON, clear labels perform better as LLM input.
AgentforceRecommendationAction.cls
public class AgentforceRecommendationAction {
public class RecommendationResult {
@InvocableVariable(
label='Result Message'
description='Confirmation message after posting recommendation'
)
public String resultMessage;
}
@InvocableMethod(
label='Generate Lead Outreach Recommendation'
description='Reads enriched lead data and posts AI recommendation to Chatter'
)
public static List<RecommendationResult> generateRecommendation(
List<Id> leadIds) {
Lead l = [
SELECT Id, Name, Title, Company, Industry,
NumberOfEmployees, AnnualRevenue, Country,
Tech_Stack__c, ICP_Score__c, ICP_Tier__c,
LinkedIn_URL__c
FROM Lead
WHERE Id = :leadIds[0]
LIMIT 1
];
// Structured text performs better than raw JSON as LLM input.
String context =
'Lead: ' + safe(l.Name) +
' | Title: ' + safe(l.Title) +
' | Company: ' + safe(l.Company) +
' | Industry: '+ safe(l.Industry) +
' | Employees: '+ (l.NumberOfEmployees != null
? String.valueOf(l.NumberOfEmployees) : 'Unknown') +
' | Revenue: ' + (l.AnnualRevenue != null
? '$' + String.valueOf(l.AnnualRevenue) : 'Unknown') +
' | Country: ' + safe(l.Country) +
' | Tech Stack: '+ safe(l.Tech_Stack__c) +
' | ICP Score: '+ (l.ICP_Score__c != null
? String.valueOf((Integer) l.ICP_Score__c) : 'N/A') +
' | Tier: ' + safe(l.ICP_Tier__c);
insert new FeedItem(
ParentId = l.Id,
Body = '[Agentforce] Enrichment complete. Score: '
+ l.ICP_Score__c + ' (' + l.ICP_Tier__c + ').
'
+ 'Context: ' + context
);
RecommendationResult result = new RecommendationResult();
result.resultMessage = 'Recommendation posted for '
+ l.Name + ', Score: ' + l.ICP_Score__c
+ ' (' + l.ICP_Tier__c + ')';
return new List<RecommendationResult>{ result };
}
private static String safe(String val) {
return String.isNotBlank(val) ? val : 'Unknown';
}
}Agent Builder Setup
In Setup > Agentforce > Agents > New Agent, I set it up as follows:
Agent Name
Lead Enrichment Advisor
Description
Reviews newly enriched lead records and generates personalised outreach recommendations for the assigned rep.
Topic Instructions
When invoked with a lead ID, review the enriched firmographic data and ICP score, then post a concise outreach recommendation to the Chatter feed. Include company context, the most relevant ICP match reason, and a suggested first-touch angle.
Action
Add the 'Generate Lead Outreach Recommendation' invocable action.
Key Technical Decisions
One lead per Queueable job
Keeps each transaction well within Salesforce governor limits. Each chained job gets fresh limits, cleaner and safer than batching callouts.
Typed inner classes in the parser
Gives compile-time safety. JSON.deserialize into a typed class is more maintainable than working with raw Map<String, Object> casts.
Set<String> for ICP lookups
O(1) hash lookup vs. O(n) linear list scan. Trivial at five items; meaningful when ICP criteria grow.
Hard disqualifiers short-circuit to 0
A student at a Fortune 500 company should not score 80. Disqualifiers prevent the scoring system from rewarding irrelevant signals.
Named Credential + Custom Setting for the API key
API keys never touch Apex source code. Named Credential handles the callout endpoint; the Protected Custom Setting stores the key value.
Log object for observability
One log record per enrichment attempt means bulk failures surface in a single list view or report, no manual checking required.