Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Automate a Salesforce Lead Workflow with Claude Code (2026)

Size a list for free, export it with LeadOcean, then let Claude Code upsert Leads into Salesforce 200 at a time with a dry run first.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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_TOKEN and $SF_INSTANCE_URL.
  • A custom text field on Lead named LeadOcean_Key__c, marked External ID and Unique.
  • Claude Code, curl and Python 3 with requests.
bash
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/mcp

The 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.

text
Use leadocean_count_leads with title "Sales Operations" and "RevOps", country US.
Tell me total, totalAll and distinctCompanies.

Claude Code sends this input.

json
{
  "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.

bash
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.

bash
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.

bash
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/download

An 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.

text
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).

python
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.

bash
python3 upsert_leads.py
python3 upsert_leads.py --send
python3 upsert_leads.py --send   # second run updates, creates nothing new

The 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.

json
{
  "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.

json
{
  "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

ErrorCauseFix
401 from api.leadocean.ioMissing or wrong x-api-keyCheck $LEADOCEAN_API_KEY is exported in the shell that runs curl
429 from api.leadocean.ioAbove the plan's rate limit: 5 requests a second on Free, 200 on AgencyWait the Retry-After seconds, or poll the export at the pollAfter interval
402 from api.leadocean.ioThe 1,000 free records are spentAdd a paid plan on /pricing
Empty results or a count of 0A filter value is not an exact enum stringList valid values with leadocean_list_enum_values and copy them exactly
Export refusedlimit is above 50,000 a request, or you passed 500,000 rows in a daySplit the export by narrower filters or run it tomorrow
400 from SalesforceThe 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 →