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.

SalesforceApexAgentforceAPI IntegrationSales Cloud

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

OutcomeDetail
Time saved per repEst. 45–60 min/day of manual research eliminated
Lead response speedEnriched data available within seconds of record creation
ICP consistencyScoring applied identically to every single lead
Rep experienceSDRs open records to context, not blank fields
Pipeline qualityHot leads surfaced immediately for fast follow-up

2. Solution Architecture

Data & Control Flow

StepComponentWhat Happens
1Lead CreatedVia web form, CSV import, or manual entry in Sales Cloud
2Apex Trigger FiresAfter-insert trigger enqueues an async Queueable job
3Queueable Job RunsMoves execution to async context, enabling HTTP callouts
4Apollo.io API CallHTTP GET /v1/people/match?email= returns enrichment JSON
5Data Written BackParser maps JSON fields to Lead standard and custom fields
6Scoring ExecutesApex scoring method calculates 0–100 ICP score
7Agentforce InvokedAgent reads score and enriched fields, generates recommendation
8Chatter Post CreatedAI recommendation posted to the Lead record Chatter feed
9Log WrittenEvery 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 NameField TypePurpose
LinkedInUrl__cURLLinkedIn profile URL from Apollo
TechStack__cLong Text AreaComma-separated technologies used (from Apollo)
EnrichmentStatus__cPicklistPending / Enriched / Failed / Skipped
IcpScore__cNumber(3,0)Calculated ICP score 0–100
IcpTier__cPicklistCold / Warm / Hot / Immediate Action
EnrichmentLastRun__cDateTimeTimestamp of last enrichment call
ApolloOrganizationId__cText(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 LabelAPI NameField TypePurpose
LeadLead__cLookup(Lead)Links the log to the Lead record
StatusStatus__cPicklistSuccess / API Error / Timeout / Parse Error
HTTP Status CodeHTTP_Status_Code__cNumber(3,0)Stores 200, 429, 500, etc.
Response BodyResponse_Body__cLong Text Area(32768)Raw API response for debugging
Error MessageError_Message__cText Area(255)Human-readable error summary
Retry CountRetry_Count__cNumber(2,0)How many times this lead was retried
TimestampTimestamp__cDateTimeWhen 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

CriteriaPointsRationale
Industry matches ICP+20Confirms vertical fit with target market
Employees > 200+15Signals sufficient budget and deal complexity
Business email domain+10Filters out consumer / non-business leads
Personal email domain−10Weaker signal, may still be a real buyer
Title is Dir / VP / C-Suite+20Indicates economic buyer or internal champion
Country in target market+15Confirms geographic addressability
Annual Revenue > $10M+20Confirms deal size is viable
Employees < 20−10Active 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.