Skip to content
All insights
Salesforce By Watson Lake Technology · May 18, 2026 · 18 min read

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 LabelAPI NameTypeNotes
AI ScoreAI_Score__cNumber (2, 0)Stores the 1–10 score
AI Score RationaleAI_Score_Rationale__cText Area (255)Plain-language explanation
AI Scored AtAI_Scored_At__cDate/TimeTimestamp 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).

  1. Setup → Custom Metadata Types → New
  2. Label: AI Scoring Config, Plural Label: AI Scoring Configs, Object Name: AI_Scoring_Config
  3. Save, then add two custom fields:
Field LabelAPI NameTypeNotes
ICP CriteriaICP_Criteria__cText Area (1000)Your scoring prompt / ICP description
Claude ModelClaude_Model__cText (100)Which Claude model to use
  1. Click Manage AI Scoring ConfigsNew 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

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-01 is required on every request and is the current stable version (Anthropic API docs)
  • max_tokens: 256 is enough for a short JSON response — keeping it low reduces cost and response time
  • The configOverride static variable lets tests inject a config without a live database record — a standard Apex testing pattern
  • The parseResponse method handles both Long and Double for the score field because JSON.deserializeUntyped maps JSON integers to Long in 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

  1. Deploy LeadScoringQueueable, LeadScoringTrigger, and LeadScoringQueueableTest to production via change set, VS Code with Salesforce CLI, or the Developer Console
  2. Verify test coverage passes (should be well above 75%)
  3. Confirm the AI_Scoring_Config__mdt Default record is in production (CMT records travel with change sets — they do not need a separate data load)
  4. 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.

VolumeEstimated 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


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.

Free playbook · PDF

10 Automations Every Salesforce Admin Should Build in 2026

Build times, impact ratings, and the exact patterns we use for clients.

Get the playbook

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