How to Build AI Lead Scoring in Salesforce with Claude: A Complete Technical Guide
A step-by-step guide to building real AI lead scoring in Salesforce using Claude's API — including Named Credentials, Queueable Apex, Custom Metadata Types, a test class, cost estimates, and data security considerations. No Agentforce license required.
This is the technical guide that follows our overview post on Claude + Salesforce automations. That post described what to build. This one shows you exactly how to build it — with real, deployable code.
What we’re building: An asynchronous Apex integration that scores every new Lead in Salesforce using Claude’s API, writes a numeric score (1–10) and a plain-language rationale back to the Lead record, and lets admins update the scoring criteria without touching code.
What this requires: Enterprise Edition or higher (Apex is not available on Professional or Group Edition without a managed package), a paid Anthropic API account, and a developer or admin with Apex deployment access.
Architecture overview
When a Lead is inserted, an Apex trigger enqueues a Queueable job. The job calls Claude’s API with the lead’s details and your ICP (Ideal Customer Profile) criteria, parses the JSON response, and writes the score back to the Lead record.
Lead inserted → Trigger (after insert) → LeadScoringQueueable → Claude API → Lead updated
We use Queueable rather than @future because:
- Queueable can accept SObjects as parameters — @future cannot
- Queueable jobs can be monitored via
AsyncApexJob - Queueable is Salesforce’s recommended approach for all new async development (Salesforce Apex Developer Guide)
The scoring criteria live in a Custom Metadata Type record, which means admins can update the ICP definition in Setup without a code deployment.
Step 1: Create custom fields on Lead
In Setup → Object Manager → Lead → Fields & Relationships, create three fields:
| Field Label | API Name | Type | Notes |
|---|---|---|---|
| AI Score | AI_Score__c | Number (2, 0) | Stores the 1–10 score |
| AI Score Rationale | AI_Score_Rationale__c | Text Area (255) | Plain-language explanation |
| AI Scored At | AI_Scored_At__c | Date/Time | Timestamp of last scoring |
Add these to the Lead page layout so reps can see them.
Step 2: Create the Custom Metadata Type
Custom Metadata Types store configuration as metadata — meaning admins can edit records directly in Setup without a deployment, and records travel with change sets (Salesforce Help: Custom Metadata Types Overview).
- Setup → Custom Metadata Types → New
- Label:
AI Scoring Config, Plural Label:AI Scoring Configs, Object Name:AI_Scoring_Config - Save, then add two custom fields:
| Field Label | API Name | Type | Notes |
|---|---|---|---|
| ICP Criteria | ICP_Criteria__c | Text Area (1000) | Your scoring prompt / ICP description |
| Claude Model | Claude_Model__c | Text (100) | Which Claude model to use |
- Click Manage AI Scoring Configs → New to create a record:
- Label:
Default - AI Scoring Config Name:
Default - ICP Criteria: Your scoring instructions (see example below)
- Claude Model:
claude-haiku-4-5
- Label:
Example ICP Criteria value:
You are a lead scoring assistant for a B2B SaaS company. Score inbound leads from 1 to 10 based on fit with our Ideal Customer Profile.
High-scoring leads (8-10): VP level or above, companies with 100-2000 employees, industries: Technology, Financial Services, Healthcare, Manufacturing. Lead sources: referral, demo request, content download.
Medium-scoring leads (4-7): Manager or Director level, companies 50-100 or 2000+ employees, other industries, or incomplete data.
Low-scoring leads (1-3): Individual/freelancer, company under 10 employees, students, job seekers, or leads with very little data.
Return ONLY valid JSON with "score" (integer 1-10) and "rationale" (string under 120 characters).
Admins can update this text at any time in Setup — no Apex deployment needed.
Step 3: Set up Named Credentials
Named Credentials store the API endpoint and authentication details so they never appear in Apex code. Salesforce deprecated the Legacy Named Credentials model starting Winter ‘23 in favor of a split External Credential + Named Credential model (Salesforce Help: Named Credentials).
3a. Create the External Credential
Setup → Named Credentials → External Credentials tab → New
- Label:
Anthropic Claude - Name:
Anthropic_Claude - Authentication Protocol:
Custom
Save, then under Principals, click New:
- Principal Name:
Admin - Sequence Number:
1 - Identity Type:
Named Principal
3b. Create the Named Credential
Setup → Named Credentials → Named Credentials tab → New
- Label:
Claude API - Name:
Claude_API(this is what you’ll reference in Apex) - URL:
https://api.anthropic.com - External Credential:
Anthropic Claude - Generate Authorization Header: unchecked
Under Custom Headers, add:
- Header Name:
x-api-key - Header Value: (your Anthropic API key)
Security note: For enterprise orgs, store the API key as a credential parameter on the External Credential Principal rather than as a static header value. See Salesforce Help: Use API Keys in Custom Headers for the full secure setup. For smaller orgs, the static header approach above is functionally equivalent — the key is not exposed in Apex code.
3c. Assign permission
The permission set linked to the External Credential Principal must be assigned to the users (or the automation profile) that will execute the Apex. In Setup → Permission Sets, find the auto-created permission set for the External Credential and assign it.
Step 4: The Queueable Apex class
Create a new Apex class: LeadScoringQueueable
public class LeadScoringQueueable implements Queueable, Database.AllowsCallouts {
private static final String NAMED_CREDENTIAL = 'Claude_API';
private static final String API_PATH = '/v1/messages';
private static final Integer CALLOUT_TIMEOUT_MS = 30000;
private static final Integer MAX_RATIONALE_LENGTH = 255;
private List<Lead> leadsToScore;
// @TestVisible allows tests to inject a config without a real CMT record
@TestVisible
private static AI_Scoring_Config__mdt configOverride = null;
public LeadScoringQueueable(List<Lead> leads) {
this.leadsToScore = leads;
}
public void execute(QueueableContext ctx) {
AI_Scoring_Config__mdt config = getConfig();
if (config == null) {
System.debug(LoggingLevel.ERROR,
'LeadScoringQueueable: No AI_Scoring_Config__mdt record named "Default" found. Aborting.');
return;
}
List<Lead> updates = new List<Lead>();
for (Lead lead : leadsToScore) {
try {
ScoringResult result = callClaude(lead, config);
updates.add(new Lead(
Id = lead.Id,
AI_Score__c = result.score,
AI_Score_Rationale__c = result.rationale,
AI_Scored_At__c = DateTime.now()
));
} catch (Exception e) {
// Log and continue — one bad callout should not block the rest
System.debug(LoggingLevel.ERROR,
'Lead scoring failed for ' + lead.Id + ': ' + e.getMessage());
}
}
if (!updates.isEmpty()) {
update updates;
}
}
private AI_Scoring_Config__mdt getConfig() {
if (configOverride != null) return configOverride;
List<AI_Scoring_Config__mdt> configs = [
SELECT ICP_Criteria__c, Claude_Model__c
FROM AI_Scoring_Config__mdt
WHERE DeveloperName = 'Default'
LIMIT 1
];
return configs.isEmpty() ? null : configs[0];
}
private ScoringResult callClaude(Lead lead, AI_Scoring_Config__mdt config) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:' + NAMED_CREDENTIAL + API_PATH);
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
// anthropic-version is required on every request — do not omit
req.setHeader('anthropic-version', '2023-06-01');
req.setTimeout(CALLOUT_TIMEOUT_MS);
Map<String, Object> body = new Map<String, Object>{
'model' => config.Claude_Model__c,
'max_tokens' => 256,
'system' => config.ICP_Criteria__c,
'messages' => new List<Object>{
new Map<String, Object>{
'role' => 'user',
'content' => buildPrompt(lead)
}
}
};
req.setBody(JSON.serialize(body));
Http http = new Http();
HttpResponse res = http.send(req);
if (res.getStatusCode() != 200) {
throw new CalloutException(
'Claude API returned ' + res.getStatusCode() + ': ' + res.getBody()
);
}
return parseResponse(res.getBody());
}
private String buildPrompt(Lead lead) {
return 'Score this inbound lead based on the ICP criteria in your instructions.\n\n' +
'Name: ' + safe(lead.FirstName) + ' ' + safe(lead.LastName) + '\n' +
'Title: ' + safe(lead.Title) + '\n' +
'Company: ' + safe(lead.Company) + '\n' +
'Industry: ' + safe(lead.Industry) + '\n' +
'Employees: ' + (lead.NumberOfEmployees != null
? String.valueOf(lead.NumberOfEmployees)
: 'Unknown') + '\n' +
'Lead Source: ' + safe(lead.LeadSource) + '\n' +
'Description: ' + safe(lead.Description) + '\n\n' +
'Return ONLY a JSON object with two fields:\n' +
'- "score": integer from 1 to 10\n' +
'- "rationale": string under 120 characters explaining the score\n' +
'Example: {"score": 7, "rationale": "VP-level at mid-market tech company, strong ICP fit, referral source"}';
}
private String safe(String value) {
return String.isNotBlank(value) ? value : 'Unknown';
}
private ScoringResult parseResponse(String responseBody) {
Map<String, Object> response =
(Map<String, Object>) JSON.deserializeUntyped(responseBody);
List<Object> content = (List<Object>) response.get('content');
Map<String, Object> firstBlock = (Map<String, Object>) content.get(0);
String text = (String) firstBlock.get('text');
// Strip markdown code fences if Claude wraps the JSON
text = text.trim()
.removeStart('```json').removeStart('```')
.removeEnd('```').trim();
Map<String, Object> parsed = (Map<String, Object>) JSON.deserializeUntyped(text);
// JSON.deserializeUntyped maps JSON integers to Long in Apex
Object scoreRaw = parsed.get('score');
Integer score;
if (scoreRaw instanceof Long) {
score = ((Long) scoreRaw).intValue();
} else if (scoreRaw instanceof Double) {
score = ((Double) scoreRaw).intValue();
} else {
score = Integer.valueOf(String.valueOf(scoreRaw));
}
// Clamp to valid range in case Claude goes out of bounds
score = Math.min(10, Math.max(1, score));
String rationale = (String) parsed.get('rationale');
if (rationale != null && rationale.length() > MAX_RATIONALE_LENGTH) {
rationale = rationale.left(MAX_RATIONALE_LENGTH);
}
return new ScoringResult(score, rationale);
}
public class ScoringResult {
public Integer score;
public String rationale;
public ScoringResult(Integer score, String rationale) {
this.score = score;
this.rationale = rationale;
}
}
}
Key decisions explained:
anthropic-version: 2023-06-01is required on every request and is the current stable version (Anthropic API docs)max_tokens: 256is enough for a short JSON response — keeping it low reduces cost and response time- The
configOverridestatic variable lets tests inject a config without a live database record — a standard Apex testing pattern - The
parseResponsemethod handles bothLongandDoublefor the score field becauseJSON.deserializeUntypedmaps JSON integers toLongin Apex
Step 5: The Lead trigger
Create a new Apex trigger on the Lead object:
trigger LeadScoringTrigger on Lead (after insert) {
List<Lead> unscored = new List<Lead>();
for (Lead l : Trigger.new) {
if (l.AI_Score__c == null) {
unscored.add(l);
}
}
if (!unscored.isEmpty()) {
System.enqueueJob(new LeadScoringQueueable(unscored));
}
}
Why after insert and not before insert? Callouts cannot be made in synchronous trigger context at all — Salesforce prohibits holding a database transaction open while waiting for an HTTP response. The Queueable runs after the transaction commits (Salesforce Apex Developer Guide — Callout Limits).
Bulk import note: Salesforce’s governor limit is 100 callouts per transaction. If you regularly import more than 100 leads in a single DML operation (trade show imports, data migrations), add batching logic to the Queueable: process the first 50 leads and chain a new job for the remainder using System.enqueueJob(new LeadScoringQueueable(remainingLeads)) inside execute().
Step 6: The test class
Salesforce requires 75% code coverage before any Apex can be deployed to production. This test class covers the success and failure paths:
@isTest
private class LeadScoringQueueableTest {
private static final String MOCK_ICP =
'Score leads 1-10. Return JSON with "score" and "rationale".';
@isTest
static void testSuccessfulScoring() {
// Inject a config so the test does not depend on a real CMT record
LeadScoringQueueable.configOverride = new AI_Scoring_Config__mdt(
ICP_Criteria__c = MOCK_ICP,
Claude_Model__c = 'claude-haiku-4-5'
);
Test.setMock(HttpCalloutMock.class, new MockClaudeSuccess());
Lead testLead = new Lead(
FirstName = 'Jane',
LastName = 'Smith',
Title = 'VP of Operations',
Company = 'Acme Corp',
Industry = 'Technology',
NumberOfEmployees = 500,
LeadSource = 'Web',
Email = 'jane.smith@acme.com',
Phone = '555-100-2000'
);
insert testLead; // trigger fires and enqueues the job
Test.startTest();
Test.stopTest(); // flushes all async jobs
Lead scored = [
SELECT AI_Score__c, AI_Score_Rationale__c, AI_Scored_At__c
FROM Lead WHERE Id = :testLead.Id
];
System.assertNotEquals(null, scored.AI_Score__c,
'Score should be set after successful API call');
System.assert(scored.AI_Score__c >= 1 && scored.AI_Score__c <= 10,
'Score must be between 1 and 10');
System.assertNotEquals(null, scored.AI_Score_Rationale__c,
'Rationale should be populated');
System.assertNotEquals(null, scored.AI_Scored_At__c,
'Scored At timestamp should be set');
}
@isTest
static void testApiFailureHandledGracefully() {
LeadScoringQueueable.configOverride = new AI_Scoring_Config__mdt(
ICP_Criteria__c = MOCK_ICP,
Claude_Model__c = 'claude-haiku-4-5'
);
Test.setMock(HttpCalloutMock.class, new MockClaudeError());
Lead testLead = new Lead(
LastName = 'Test',
Company = 'Test Corp',
Email = 'test@testcorp.com',
Phone = '555-000-0000'
);
insert testLead;
Test.startTest();
Test.stopTest();
// Lead should exist but remain unscored — no unhandled exception thrown
Lead unscored = [SELECT AI_Score__c FROM Lead WHERE Id = :testLead.Id];
System.assertEquals(null, unscored.AI_Score__c,
'Score should remain null after API failure');
}
// Simulates a successful Claude API response
private class MockClaudeSuccess implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(200);
res.setHeader('Content-Type', 'application/json');
res.setBody(
'{"id":"msg_test","type":"message","role":"assistant",' +
'"content":[{"type":"text","text":' +
'"{\"score\":8,\"rationale\":\"VP-level at 500-person tech company — strong ICP fit\"}"}],' +
'"model":"claude-haiku-4-5","stop_reason":"end_turn",' +
'"usage":{"input_tokens":180,"output_tokens":22}}'
);
return res;
}
}
// Simulates a Claude API server error
private class MockClaudeError implements HttpCalloutMock {
public HttpResponse respond(HttpRequest req) {
HttpResponse res = new HttpResponse();
res.setStatusCode(500);
res.setBody('{"type":"error","error":{"type":"api_error","message":"Internal server error"}}');
return res;
}
}
}
Step 7: Deploy and configure
- Deploy
LeadScoringQueueable,LeadScoringTrigger, andLeadScoringQueueableTestto production via change set, VS Code with Salesforce CLI, or the Developer Console - Verify test coverage passes (should be well above 75%)
- Confirm the
AI_Scoring_Config__mdtDefault record is in production (CMT records travel with change sets — they do not need a separate data load) - Insert a test Lead manually and check the AI Score fields 5–10 seconds later
Cost estimates
All figures based on verified pricing from Anthropic’s pricing page as of May 2026.
claude-haiku-4-5: $1.00 per million input tokens, $5.00 per million output tokens
A typical lead scoring prompt (ICP criteria + lead fields) runs approximately 300–400 input tokens. Claude’s response (score + rationale JSON) runs approximately 30–40 output tokens.
| Volume | Estimated monthly cost (Haiku) |
|---|---|
| 500 leads/month | ~$0.20 |
| 2,000 leads/month | ~$0.70 |
| 10,000 leads/month | ~$3.50 |
| 50,000 leads/month | ~$17.50 |
claude-sonnet-4-6 ($3.00 input / $15.00 output per MTok) delivers more nuanced scoring for complex ICPs. At 50,000 leads/month, cost increases to approximately $60–70/month — still likely worth it for most sales orgs.
Using the Batch API (asynchronous, not suitable for real-time trigger-based scoring) cuts costs by 50% and is worth considering for nightly re-scoring jobs.
Data security and compliance considerations
Does Anthropic train on API data? No. Anthropic’s Commercial Terms of Service explicitly state that Anthropic may not train models on customer content from the API (Anthropic Commercial Terms). This is distinct from consumer claude.ai accounts, which have different terms.
Does lead data leave Salesforce’s trust boundary?
Yes. Every callout sends lead field values — name, title, company, industry, employee count, lead source — to Anthropic’s API at api.anthropic.com. This is a critical distinction from Agentforce, which keeps data within Salesforce’s trust layer.
What this means for regulated industries:
- HIPAA: If your Salesforce org contains Protected Health Information (PHI), verify with your compliance team before sending any fields via API callout. Standard Lead fields (name, title, company) are generally not PHI, but be deliberate about what you include in
buildPrompt() - GDPR / CCPA: You are the data controller; Anthropic is the data processor. Ensure your privacy policy covers AI processing of contact data and that you have a legitimate legal basis under GDPR (legitimate interest is typically applicable for B2B lead scoring)
- Financial services: Review your data processing agreements before sending prospect data to third-party APIs
Minimizing exposure: Only send fields that the scoring logic genuinely needs. The buildPrompt() method in this guide deliberately excludes email addresses, phone numbers, and personal contact details — they don’t affect ICP fit scoring, and excluding them reduces the data footprint.
Pitfalls to watch for
1. Governor limit on bulk inserts Salesforce allows 100 callouts per transaction. If a data import triggers 200+ leads in a single DML, the Queueable will hit the limit. Add chunking: process the first 50 leads, chain a new job for the remainder.
2. Claude returning malformed JSON
With correct prompting (returning ONLY valid JSON, providing an example), this is rare but possible. The parseResponse method strips markdown code fences that Claude occasionally wraps responses in. Add error logging if you see jobs failing silently in production.
3. @future limit confusion
The @future annotation has a limit of 50 calls per transaction. System.enqueueJob() (Queueable) has a separate limit of 50 enqueues per transaction. The trigger enqueues one job regardless of how many leads were inserted — this is intentional and keeps you well under limits.
4. Scoring fires on every insert, including imports
The trigger fires on after insert for every Lead, including data loads. If you’re migrating historical leads and don’t want to score them, add a condition: check a custom checkbox field like Skip_AI_Scoring__c and set it on imported records.
5. Named Credential permission set If the Queueable fails with a callout error referencing permissions, the External Credential permission set is not assigned. Check the user (or automation profile) running the Queueable.
6. Re-scoring on update
This guide only scores on insert. If you want to re-score when key fields change (title, company, industry), add a second trigger entry after update with a field-comparison condition.
When Einstein Lead Scoring is the better choice
This Claude-based approach requires Apex and an external API call. Einstein Lead Scoring is worth considering when:
- You need a fully declarative setup with no Apex. Einstein Lead Scoring is configured entirely in Setup → Einstein → Lead Scoring and trains on your historical conversion data automatically
- Your data cannot leave Salesforce’s trust layer due to regulatory requirements. Einstein keeps everything within Salesforce
- You need a business admin to own the logic without developer involvement. Einstein retrains itself; Claude’s scoring prompt requires a developer to update the Queueable if you need to change the field mix, though the ICP criteria itself is admin-editable via Custom Metadata
- You have a Sales Cloud Einstein license already. If you’re paying for it, use it
The Claude approach wins when: you want explicit, readable scoring criteria instead of a black-box ML model; you want to score leads before you have conversion history for Einstein to train on; or you want the rationale field — a plain-English explanation of the score that reps actually find useful.
Sources
- Anthropic Messages API Documentation
- Anthropic Pricing
- Anthropic Commercial Terms of Service — data training policy
- Anthropic API Rate Limits
- Salesforce Apex Developer Guide — Queueable Apex
- Salesforce Apex Developer Guide — Callout Limits and Limitations
- Salesforce Help — Named Credentials
- Salesforce Help — Use API Keys in Custom Headers
- Salesforce Help — Custom Metadata Types Overview
- Salesforce Apex Developer Guide — Execution Governors and Limits
If you’re implementing this and hit a wall — wrong error, unexpected behavior, or a use case this guide doesn’t cover — get in touch. This is the kind of work we do day-to-day.
10 Automations Every Salesforce Admin Should Build in 2026
Build times, impact ratings, and the exact patterns we use for clients.
More in Salesforce
Claude Code for Salesforce Admins: A Practical Starter Guide
How Salesforce admins and developers can use Claude Code to ship Apex, LWC, and Flow work in hours instead of days — without replacing the discipline that keeps production orgs healthy.
Salesforce Flow → Apex: When to Migrate, When to Stay
A field guide to deciding which Flows are worth rewriting as Apex, and a practical step-by-step process for the migration. Based on dozens of real-world conversions.
How Much Does a Salesforce Implementation Cost? (2026 Guide)
Honest price ranges for Salesforce Sales Cloud, Service Cloud, Experience Cloud, and enterprise implementations in 2026 — what drives cost and how to scope a project before talking to anyone.
Have a system you'd like us to build?
We turn repetitive work into automations that run in the background — so your team does the work that matters.
Multiply Your Output