You get a job that finds Salesforce Contacts with a blank email, phone or title, enriches each one through LeadOcean, and writes the answers back. It runs outside Salesforce, so it needs no package and no Apex. It talks to Salesforce through Bulk API 2.0 and to LeadOcean through https://api.leadocean.io. LeadOcean has no Salesforce app. This is a plain HTTP recipe.
How it works
Bulk API 2.0 suits this job. Salesforce says any operation over 2,000 records is a good candidate for it, and in Bulk API 2.0 only ingest jobs consume batches, not query jobs (Salesforce, September 2026).
- Add two Contact fields. Create a date field
LeadOcean_Enriched_On__cand a text fieldLeadOcean_Email_Status__c. The date is your dedupe flag. - Get a LeadOcean key. Sign up at app.leadocean.io/register, no card. Export it as
LEADOCEAN_API_KEY. - Query the gap.
POST /services/data/vXX.X/jobs/querywith a SOQL string. Results come back as CSV (Salesforce, September 2026). - Enrich each row. For every distinct email in the CSV, call
POST /v1/people/enrich. Sendlinkedin_urlinstead when the Contact has one and no email. - Write back. Build a CSV keyed on the Contact
Idand create a Bulk API 2.0 ingest job with operationupdate. Upload it, then set the state toUploadComplete(Salesforce, September 2026). - Schedule it. Run the script from cron or any CI scheduler, daily or weekly. The date filter in step 3 means each run only touches new gaps.
Keep the first run small. Add LIMIT 100 to the SOQL, run the whole loop, and open five Contacts in Salesforce to check the values. Then raise the limit. A sandbox is the safest place for that first pass.
Use update, not upsert. Every row already exists in Salesforce, so write by Id. Upsert needs an external ID field and suits loads where records may be new (Salesforce, September 2026). Writing by Id also means the job never creates a duplicate Contact.
Configuration
The SOQL in step 3. It picks Contacts that have a LinkedIn URL or email, are missing data, and were never enriched:
{
"operation": "query",
"query": "SELECT Id, Email, Phone, Title, LinkedIn_URL__c FROM Contact WHERE LeadOcean_Enriched_On__c = NULL AND (Email = NULL OR Phone = NULL OR Title = NULL) AND (Email != NULL OR LinkedIn_URL__c != NULL) LIMIT 10000"
}LinkedIn_URL__c is a custom field name. Use whatever yours is called. The enrich call in step 4, with a placeholder email:
curl -X POST 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","reveal_email":true}'Send exactly one of linkedin_url, email, phone or person_id. reveal_email costs no extra record, and phones only come back when it is true. A person LeadOcean holds nothing for is a 404 and costs nothing. Stamp those Contacts with today's date anyway, so they are not retried every run.
The ingest job body for step 5:
{ "object": "Contact", "operation": "update", "contentType": "CSV", "lineEnding": "LF" }Field mapping
The enrich response is one leadocean.person.v1 object under data, with profile_data, contact_data and resume_data (LeadOcean OpenAPI, October 2026). Paths below start inside data.
| LeadOcean field | Salesforce field | Note |
|---|---|---|
contact_data.contact_emails[].email | Email | Write only if Email is blank. Pick the entry whose type is work. |
contact_data.contact_emails[].status | LeadOcean_Email_Status__c | verified, catch_all_valid or catch_all are mailable. Read it before sending. |
contact_data.contact_phones[].phone | Phone | Write only if blank. Skip the field when none comes back. |
contact_data.contact_current_experiences[].job_title | Title | Current role. Overwrite only a blank or stale title. |
contact_data.contact_current_experiences[].company_domain | Account.Website | Use for account matching, not Contact writes. |
profile_data.profile_headline | Description | Optional. Handy for routing. |
| today's date | LeadOcean_Enriched_On__c | Your dedupe flag. Set it on 404s too. |
Dedupe in three places. The date field stops reruns. Calling LeadOcean once per distinct email stops paying twice for duplicate Contacts. Writing by Id creates no new records.
Past roles come back too. Since October 2026 the response carries resume_data.experiences[] with title, company name and dates for each past role LeadOcean holds. Past employers have no company id yet. Write them to a long text field if your reps use job history.
Cost at 10,000 rows
Enriching 10,000 Contacts costs 10,000 records on LeadOcean, which fits Solo at $97 a month. Solo allows 10k enrichments every 5 hours and 50k a week, at 50 requests a second. Free holds 1,000 records for life, enough to test one slice. Agency is $497 a month if a team shares the pace.
| LeadOcean | Salesforce | |
|---|---|---|
| Test run | Free: 1,000 records, one-off, no card | Developer Edition: 15,000 API calls a day |
| 10,000 rows | Solo, $97 a month. No per-record price | Bulk API 2.0: a few calls, 1 batch for up to 10,000 records |
| Row-by-row REST instead | Same record count | 10,000 API calls against the daily total |
Salesforce sets the daily API total per org. Enterprise Edition gets 100,000 plus per-license calls plus purchased add-ons, and API calls from Bulk API 2.0 count toward it (Salesforce, September 2026). Bulk jobs are limited to 15,000 batches per rolling 24 hours, and a batch holds up to 10,000 records. One 10,000-row update job is about one batch. Query jobs consume none.
At 50 requests a second, 10,000 enrich calls take about 200 seconds. See pricing.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 from LeadOcean | Key missing or wrong | Send it in the x-api-key header, no spaces. |
| 400 from LeadOcean | Two identifiers sent, or none | Send one of email or linkedin_url. |
| 402 from LeadOcean | Free 1,000 records spent | Move to Solo. Free records do not renew. |
| 429 from LeadOcean | Over the plan's rate limit | Wait the Retry-After seconds. Free is 5 a second, Solo 50. |
REQUEST_LIMIT_EXCEEDED from Salesforce | Too many long-running requests at once | Production orgs allow 25 concurrent requests of 20 seconds or more. Run fewer in parallel. |
| Ingest job fails on a row | Bad value for a field type | Fetch failedResults for the job and fix those rows. |
| Same Contacts come back every run | Dedupe date not written | Stamp LeadOcean_Enriched_On__c on every row, 404s included. |
FAQ
Does LeadOcean have a Salesforce app?
No. It is a REST API and an MCP server. This recipe calls the REST API from a script.
How do I get phone numbers?
Set reveal_email to true on the enrich call and read contact_data.contact_phones[].phone. For a short list you can also call GET /v2/people/phone. That endpoint costs one record per call, including a call that finds nothing.
Can I enrich Accounts too?
Yes. POST /v1/companies/enrich takes a domain. Query Accounts with a blank industry or headcount, then write back the same way.
How fresh is the data?
The dataset refreshes monthly, and each record carries its own fetched_at date. Rerun the job monthly with the dedupe date cleared for stale rows. More recipes sit in the integrations hub, such as Retool, Notion and Folk.
Enrich your first Salesforce Contacts on the free plan
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →