TL;DR
- Claude Code writes Apex well when you give it a spec, a test target and the
sfCLI. This guide builds one real class: a Queueable that enriches a new Lead through LeadOcean. - It takes about 40 minutes the first time. Testing on a scratch org spends about 2 of your 1,000 free records.
- Claude Code drafts. You still read the diff, run the tests and review governor limits before anything reaches production.
Prerequisites
- A Salesforce scratch org or developer sandbox. Never start in production.
- The Salesforce CLI (
sf), authorised to that org, and an SFDX project. - Claude Code installed in the project folder.
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off.
- A LeadOcean API key exported as
$LEADOCEAN_API_KEYfor the REST checks.
export LEADOCEAN_API_KEY="paste-your-key-here"
sf org listThe examples use acme.com and Jane Doe as placeholders.
Step 1: Give Claude Code the rules in CLAUDE.md
Claude Code reads a CLAUDE.md file in the project root at the start of a session. Put your Apex standards there once, so every class follows them.
Apex rules for this repo:
- API version 62.0. One trigger per object, logic in handler classes.
- No SOQL or DML inside loops. Bulkify everything.
- Callouts only through a Named Credential called LeadOcean. Never a literal key.
- Triggers cannot call out directly. Use Queueable with Database.AllowsCallouts.
- Every class needs a test with HttpCalloutMock and 90%+ coverage.
- Run `sf apex run test --code-coverage --result-format human` before saying done.
- Deploy only to the scratch org alias `dev`. Never to production.Do not paste your key into this file or into a prompt. The Named Credential holds it.
Step 2: Size the problem for free
Before writing a line, check what the callout will return for. A count costs 0 records. Connect the MCP server once, then ask.
claude mcp add --transport http --scope user leadocean https://api.leadocean.io/mcp
claude mcp login leadoceanUse the LeadOcean MCP server only. Call leadocean_count_leads with
title ["Salesforce Developer"] and country ["US"]. Report total, totalAll and
distinctCompanies, and say which email statuses the total counted. Do not pull anyone.The server exposes 13 tools. Seven never spend records. The full walkthrough is on Connect LeadOcean to Claude Code.
Step 3: Have Claude Code write the Queueable
Give it a spec, not a vague ask. Name the endpoint, the input and what to write back.
Create force-app/main/default/classes/LeadEnrichQueueable.cls.
- Implements Queueable, Database.AllowsCallouts. Takes a List<Id> of Leads.
- For each Lead with an email, POST to callout:LeadOcean/v1/people/enrich
with body {"email": <email>}. One call per Lead.
- On 200, write data.contact_data.contact_current_experiences[0].job_title
into Lead.Title only if Title is blank.
- On 404, do nothing. On 429, re-enqueue once. Log other codes with System.debug.
- Process at most 50 Leads per job. Add a LeadTrigger handler that enqueues it.
Then write the test class with an HttpCalloutMock. Do not deploy yet.Expect something close to this. Read every line before you accept it.
public with sharing class LeadEnrichQueueable implements Queueable, Database.AllowsCallouts {
private List<Id> leadIds;
public LeadEnrichQueueable(List<Id> leadIds) { this.leadIds = leadIds; }
public void execute(QueueableContext ctx) {
List<Lead> leads = [SELECT Id, Email, Title FROM Lead WHERE Id IN :leadIds];
List<Lead> toUpdate = new List<Lead>();
for (Lead l : leads) {
if (String.isBlank(l.Email)) continue;
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:LeadOcean/v1/people/enrich');
req.setMethod('POST');
req.setHeader('content-type', 'application/json');
req.setBody(JSON.serialize(new Map<String, String>{ 'email' => l.Email }));
HttpResponse res = new Http().send(req);
if (res.getStatusCode() == 200 && String.isBlank(l.Title)) {
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
Map<String, Object> data = (Map<String, Object>) body.get('data');
Map<String, Object> contact = (Map<String, Object>) data?.get('contact_data');
List<Object> roles = (List<Object>) contact?.get('contact_current_experiences');
if (roles != null && !roles.isEmpty()) {
Map<String, Object> role = (Map<String, Object>) roles[0];
if (role.get('job_title') != null) {
l.Title = (String) role.get('job_title');
toUpdate.add(l);
}
}
}
}
if (!toUpdate.isEmpty()) update toUpdate;
}
}The current role sits in contact_data.contact_current_experiences[], per the enrich docs. Step 5 confirms it against a live response.
Step 4: Set up the Named Credential and deploy
Create a Named Credential called LeadOcean that points at https://api.leadocean.io. A named credential holds the endpoint URL and its authentication in one definition, and Apex reaches it as callout:LeadOcean/... (Salesforce Apex docs, October 2026). LeadOcean expects the key in an x-api-key header. Configure that header on the credential in Setup, following Salesforce Help for your org, so the key never appears in Apex.
Then let Claude Code deploy and test against the scratch org.
sf project deploy start --target-org dev
sf apex run test --target-org dev --code-coverage --result-format human --wait 10Paste failures back into the session. Claude Code is good at fixing a compile error or a failing assertion from the output. It cannot see your org's validation rules, so tell it about them.
Step 5: Check the real response shape
Before trusting the field path, call the endpoint once yourself. This spends 1 record if the person is found, and nothing on a 404.
curl -s https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{"email": "jane.doe@acme.com"}' | jq 'keys, (.data | keys)'Paste the output into the session and ask Claude Code to correct the parser. Enrich takes exactly one of linkedin_url, email, phone or person_id. Since October 2026 it also returns career history under resume_data.
What you get
The count in Step 2 is a real result. We ran it through the free leadocean_count_leads tool on 2026-10-21 (trimmed).
{
"total": 3563,
"totalIsExact": true,
"totalAll": 13143,
"distinctCompanies": 1788,
"credits": 0,
"defaults": "Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)",
"unsupportedFilters": []
}That is 3,563 mailable people titled Salesforce Developer in the US, across 1,788 companies. Without an email filter the same filters match 13,143. Your number will differ as the data changes.
The enrich response, as the API reference describes it, with placeholder values (response shape from the API reference):
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_current_experiences": [
{ "company_name": "Acme", "company_domain": "acme.com", "job_title": "Salesforce Developer" }
]
},
"resume_data": { "experiences": [] }
},
"meta": { "credits": 1 }
}The test run enriches one Lead, which is 1 record. A Lead we hold nothing for is a 404 and costs nothing.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong key in the Named Credential header | Re-copy the key from app.leadocean.io and update the credential. |
| 429 | Above your plan's rate limit: 5 requests a second on Free, 200 on Agency | Enqueue smaller batches and honour Retry-After. Claude Code can add the single retry. |
| 402 | The 1,000 free records are spent | Check GET /v1/account or move to Solo. |
| 404 on enrich | We hold nothing for that email | Skip the Lead. A 404 costs nothing. |
| Count of 0 or 400 on a filter | A value outside the catalogue | Call leadocean_list_enum_values and pass the exact string. |
| Callout not allowed from a trigger | The class makes the HTTP call inside the trigger context | Keep the call in the Queueable, as in Step 3. |
FAQ
Can Claude Code deploy to production?
It can run any sf command you allow, so do not allow that one. Keep a scratch org alias in CLAUDE.md and deploy to production through your normal pipeline after review.
Does Claude Code know Apex governor limits?
It knows the common ones, such as no SOQL in loops. It does not know your org's data volume. Review batch sizes and run the tests with realistic data.
How many records does the enrichment cost at scale?
One per Lead found. Misses cost nothing. Paid plans run on a fair-use pace, not credits, so there is no per-record invoice. See pricing.
Does LeadOcean have a Salesforce package?
No. LeadOcean returns data over REST and MCP. You write the Apex, as above, or sync from a tool such as Clay.
More Salesforce recipes: workflow automation, skills for admins and MCP setup. All recipes live in the blog hub.
Enrich your Salesforce Leads from Apex, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →