The reliable pattern is a script that Claude Code writes and you run. LeadOcean supplies the people, and Salesforce's sObject Collections upsert writes them as Leads, keyed on an external ID so a rerun never duplicates.
Keep the chat for design and the script for volume. This guide builds one workflow: a list of US sales operations people, landed in Salesforce as Leads.
The reason to script it is control. A chat session that writes to a CRM row by row is slow, hard to review and hard to rerun. A script with a dry run and an external ID is easy to check, safe to repeat and cheap to hand to a colleague.
TL;DR
- It takes about 45 minutes the first time and about 10 for each new list.
- Sizing the list is free. The 200-person test spends 200 LeadOcean records, which is 20% of the 1,000 free ones.
- Salesforce upserts up to 200 records per request, so 200 Leads is one write call (Salesforce REST API guide, September 2026).
Prerequisites
- A LeadOcean API key from app.leadocean.io, stored as
$LEADOCEAN_API_KEY. Free has no card and 5 requests a second. - A Salesforce sandbox, not production, with an access token and instance URL in
$SF_ACCESS_TOKENand$SF_INSTANCE_URL. - A custom text field on Lead named
LeadOcean_Key__c, marked External ID and Unique. - Claude Code,
curland Python 3 withrequests.
export LEADOCEAN_API_KEY="paste-your-key-here"
export SF_INSTANCE_URL="https://your-domain.my.salesforce.com"
export SF_ACCESS_TOKEN="paste-your-token-here"
claude mcp add --transport http leadocean https://api.leadocean.io/mcpThe last line adds the LeadOcean MCP server. It signs in with OAuth, so no key is pasted into the chat. sf org display --json prints the instance URL as result.instanceUrl. Current Salesforce CLI versions redact the access token in that output (salesforcecli/plugin-org source, October 2026), so take the token from your org's OAuth flow.
Step 1: Size the list before you spend anything
Ask Claude Code to count first. leadocean_count_leads is free however often you call it. Counting first tells you whether the list is 300 people or 300,000 before a single record is spent.
Use leadocean_count_leads with title "Sales Operations" and "RevOps", country US.
Tell me total, totalAll and distinctCompanies.Claude Code sends this input.
{
"title": ["Sales Operations", "RevOps"],
"country": ["US"]
}The same count over REST is a free search with count=true as a query parameter and limit=1.
curl -s -H "x-api-key: $LEADOCEAN_API_KEY" \
"https://api.leadocean.io/v1/people/search?count=true&limit=1&title=Sales%20Operations,RevOps&country=US"Title is a keyword match, so read the facets before you trust it. Narrow with jobLevel or seniority if the industry mix looks wrong.
Step 2: Export the people as a CSV
One export request returns a file you own. That beats paging search results through a chat window.
curl -X POST https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"sfdc test 200","filters":{"title":["Sales Operations","RevOps"],"country":["US"],"emailStatus":["verified","catch_all_valid"],"emailType":["work"]},"limit":200,"columns":["profile_first_name","profile_last_name","current_job_title","current_company_name","current_company_domain","profile_url","email_1_address","email_1_status"]}'Poll the export, then download it. The pollAfter field says how many seconds to wait.
curl -H "x-api-key: $LEADOCEAN_API_KEY" https://api.leadocean.io/v1/exports/EXPORT_ID
curl -H "x-api-key: $LEADOCEAN_API_KEY" -o people.csv https://api.leadocean.io/v1/exports/EXPORT_ID/downloadAn export takes up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on Free, so on Free run the same filters through search and enrich instead. The Exports page in app.leadocean.io builds the same file with a filter builder and shows the record price first.
Step 3: Give Claude Code a spec, not a vague task
Write the rules into the prompt. A script that cannot touch production beats a careful reviewer.
Write upsert_leads.py. It reads people.csv and upserts Salesforce Leads.
Rules:
- Skip rows where email_1_status is not verified or catch_all_valid.
- Map: profile_first_name to FirstName, profile_last_name to LastName,
current_job_title to Title, current_company_name to Company,
current_company_domain to Website, email_1_address to Email,
lowercased email_1_address to LeadOcean_Key__c.
- LastName and Company are required on Lead. Skip rows missing either and count them.
- PATCH /services/data/v62.0/composite/sobjects/Lead/LeadOcean_Key__c in chunks of 200.
- Add LeadSource "LeadOcean".
- Default to a dry run that prints the first 3 payloads and sends nothing.
Send only with --send.
- Read SF_INSTANCE_URL and SF_ACCESS_TOKEN from the environment. Never print them.Replace v62.0 with your org's current API version. Review the diff before you run anything. Check that the field map matches your org, that the external ID field is the one in the URL, and that nothing prints a token.
Step 4: The upsert script
This is the shape Claude Code should produce. The endpoint, the 200 limit and the external ID in the path come from Salesforce's documentation (September 2026).
import csv, json, os, sys, requests
OK = {"verified", "catch_all_valid"}
URL = (os.environ["SF_INSTANCE_URL"]
+ "/services/data/v62.0/composite/sobjects/Lead/LeadOcean_Key__c")
HEAD = {"Authorization": "Bearer " + os.environ["SF_ACCESS_TOKEN"],
"Content-Type": "application/json"}
records, skipped = [], 0
for r in csv.DictReader(open("people.csv")):
if r["email_1_status"] not in OK or not r["profile_last_name"] or not r["current_company_name"]:
skipped += 1
continue
records.append({
"attributes": {"type": "Lead"},
"FirstName": r["profile_first_name"],
"LastName": r["profile_last_name"],
"Title": r["current_job_title"],
"Company": r["current_company_name"],
"Website": r["current_company_domain"],
"Email": r["email_1_address"],
"LeadSource": "LeadOcean",
"LeadOcean_Key__c": r["email_1_address"].lower(),
})
print(f"{len(records)} to upsert, {skipped} skipped")
if "--send" not in sys.argv:
print(json.dumps(records[:3], indent=2))
sys.exit(0)
for i in range(0, len(records), 200):
body = {"allOrNone": False, "records": records[i:i + 200]}
res = requests.patch(URL, headers=HEAD, json=body)
res.raise_for_status()
results = res.json()
failed = [x for x in results if not x["success"]]
print(f"chunk {i // 200}: {len(results) - len(failed)} ok, {len(failed)} failed")
for x in failed[:5]:
print(x["errors"])allOrNone: False lets good rows land when one row fails. Salesforce returns results in request order, so a failure maps back to a CSV line. Set it to True if you would rather roll back a whole chunk than clean up partial writes.
Step 5: Dry run, then send
Run it without --send first and read the three payloads. Then run it against the sandbox.
python3 upsert_leads.py
python3 upsert_leads.py --send
python3 upsert_leads.py --send # second run updates, creates nothing newThe second send is the test that matters, because it proves the script is safe to schedule. Because LeadOcean_Key__c is the external ID, the rerun updates the same 200 Leads and creates none. Count Leads where LeadSource = 'LeadOcean' in the sandbox to confirm.
What you get
Step 1 on 2026-10-21, using the free leadocean_count_leads tool, titles "Sales Operations" and "RevOps", country US.
{
"total": 34246,
"totalIsExact": true,
"totalAll": 81003,
"distinctCompanies": 13034,
"credits": 0
}total counts people with an address worth sending to (verified, catch_all_valid or catch_all). totalAll is the same filters with no email condition. The largest industry in the facets was Software Development at 4,911 people. The call spent 0 records.
A person row from the API has this shape. Response shape from the API reference, placeholder values.
{
"success": true,
"data": [
{
"profile_data": {},
"contact_data": { "has_email": true, "email_status": "verified" },
"resume_data": {},
"meta": {}
}
],
"meta": { "count": 1, "limit": 1 }
}The export CSV is flat, with columns such as profile_first_name, current_job_title and email_1_address. A row looks like Jane,Doe,Director of Sales Operations,Acme,acme.com,verified, with placeholder values.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 from api.leadocean.io | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported in the shell that runs curl |
| 429 from api.leadocean.io | Above the plan's rate limit: 5 requests a second on Free, 200 on Agency | Wait the Retry-After seconds, or poll the export at the pollAfter interval |
| 402 from api.leadocean.io | The 1,000 free records are spent | Add a paid plan on /pricing |
| Empty results or a count of 0 | A filter value is not an exact enum string | List valid values with leadocean_list_enum_values and copy them exactly |
| Export refused | limit is above 50,000 a request, or you passed 500,000 rows in a day | Split the export by narrower filters or run it tomorrow |
| 400 from Salesforce | The request body is not well formed (Salesforce, September 2026) | Fix the JSON, and check every record has attributes.type set to Lead |
FAQ
How many LeadOcean records does this workflow spend?
One per person exported. The 200-person test spends 200, and the count in Step 1 spends none. A search that returns nobody, an enrich that finds nothing and a count cost nothing.
Can I use Bulk API 2.0 instead of sObject Collections?
Yes, for lists far past a few thousand rows. This guide uses sObject Collections because it needs no job management and returns per-row results in the response. Ask Claude Code to port the script and keep the same external ID.
Does Claude Code write to Salesforce directly?
Only if you give it credentials. The pattern here keeps credentials in environment variables and makes the first run a dry run. Point it at a sandbox until you have read the output.
What if I am on the Free plan?
Run the same filters through /v1/people/search and enrich the people you keep with POST /v1/people/enrich. Both spend records from the 1,000 you get for life. Bulk CSV export needs a paid plan.
Where do the Salesforce skills for admins fit?
They cover metadata, reports and flows inside the org. This guide covers the data feeding it. Read Claude Code Salesforce skills for admins for that side, and the HubSpot version of this workflow if you run both CRMs.
Related: build a B2B prospecting workflow with Claude Code, all guides and pricing.
Fill Salesforce with a list you sized for free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →