You enrich a Databricks table by running a notebook on a schedule. It reads rows with no enrichment yet, calls LeadOcean once per row, and merges the email, phone and profile back into the Delta table.
Databricks documents running jobs on a schedule, September 2026, and merging into Delta tables, September 2026. This guide uses both.
TL;DR
- It takes about 40 minutes: 10 for the tables, 15 for the notebook, 15 to schedule and test on a small batch.
- It spends 1 record per matched row. A batch of 50 costs up to 50 of your 1,000 free records. A row we hold nothing for is a 404 and costs 0.
- Keep
linkedin_urloremailon every row. There is no name plus domain lookup, so one of those is the lookup key.
Prerequisites
- A Databricks workspace with a cluster or serverless compute that can reach the internet, and a Unity Catalog schema you can write to (examples use
main.sales). - A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off.
- The Databricks CLI to create a secret scope (see the secrets docs), and the key as
$LEADOCEAN_API_KEYin your shell. It never goes in a notebook or in Git.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Create the tables
Rows start with enriched_at empty, which is how the job finds its work. Keep the raw answer as a JSON string and pull out the fields you query.
CREATE TABLE IF NOT EXISTS main.sales.leads (
id BIGINT GENERATED ALWAYS AS IDENTITY,
linkedin_url STRING,
email STRING,
enriched_at TIMESTAMP,
enrich_status STRING, -- matched | not_found
work_email STRING,
email_status STRING,
phone STRING,
profile STRING
);
CREATE TABLE IF NOT EXISTS main.sales.companies (
id BIGINT GENERATED ALWAYS AS IDENTITY,
domain STRING,
enriched_at TIMESTAMP,
company STRING
);
INSERT INTO main.sales.leads (linkedin_url)
VALUES ('https://www.linkedin.com/in/janedoe-example');
INSERT INTO main.sales.companies (domain) VALUES ('acme.com');Both rows are placeholders. Swap in your own data.
Step 2: Store the key in a secret scope
Databricks keeps credentials in secret scopes, September 2026. Create one and put the key in it.
databricks secrets create-scope leadocean
databricks secrets put-secret leadocean api_key --string-value "$LEADOCEAN_API_KEY"The notebook reads it with dbutils.secrets.get. Give the job's run-as user read access to that scope and no one else.
Step 3: Write the notebook
The notebook takes up to 50 pending rows from each table, calls POST /v1/people/enrich with reveal_email true, and merges the answers back. It sends one request at a time with a 0.25 second pause, which stays under the 5 requests a second on Free.
import json, time, requests
from datetime import datetime, timezone
KEY = dbutils.secrets.get("leadocean", "api_key")
API = "https://api.leadocean.io"
H = {"x-api-key": KEY, "Content-Type": "application/json"}
class Stop(Exception):
pass
def call(path, body):
time.sleep(0.25) # stays under 5 requests a second on Free
r = requests.post(API + path, headers=H, json=body, timeout=30)
if r.status_code == 404:
return None
if r.status_code in (401, 402):
raise Stop(f"stop:{r.status_code}")
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "1")))
return call(path, body)
r.raise_for_status()
return r.json().get("data")
now = datetime.now(timezone.utc)
people, cos = [], []
try:
pending = spark.table("main.sales.leads").where("enriched_at IS NULL").limit(50).collect()
for row in pending:
key = {"linkedin_url": row.linkedin_url} if row.linkedin_url else {"email": row.email}
d = call("/v1/people/enrich", {**key, "reveal_email": True})
if d is None:
people.append((row.id, "not_found", None, None, None, None))
continue
c = d.get("contact_data") or {}
people.append((
row.id, "matched",
(c.get("contact_emails") or [{}])[0].get("email"),
c.get("email_status"),
(c.get("contact_phones") or [{}])[0].get("phone"),
json.dumps(d.get("profile_data")),
))
for row in spark.table("main.sales.companies").where("enriched_at IS NULL").limit(50).collect():
d = call("/v1/companies/enrich", {"domain": row.domain})
cos.append((row.id, json.dumps(d) if d else None))
except Stop as e:
print(e) # bad key or records spent: write what we have, then end
cols = "id long, enrich_status string, work_email string, email_status string, phone string, profile string"
if people:
spark.createDataFrame(people, cols).createOrReplaceTempView("people_updates")
spark.sql(f"""
MERGE INTO main.sales.leads t USING people_updates s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.enrich_status = s.enrich_status,
t.work_email = s.work_email, t.email_status = s.email_status,
t.phone = s.phone, t.profile = s.profile,
t.enriched_at = timestamp'{now:%Y-%m-%d %H:%M:%S}'
""")
if cos:
spark.createDataFrame(cos, "id long, company string").createOrReplaceTempView("company_updates")
spark.sql(f"""
MERGE INTO main.sales.companies t USING company_updates s ON t.id = s.id
WHEN MATCHED THEN UPDATE SET t.company = s.company,
t.enriched_at = timestamp'{now:%Y-%m-%d %H:%M:%S}'
""")
print({"people": len(people), "companies": len(cos)})A 401 or 402 raises Stop, so a bad key or spent records never loops. Rows left untouched are picked up on the next run.
Step 4: Schedule the job
Add the notebook as a task in a job, then use the Scheduled trigger. Databricks lets you pick a simple or advanced schedule, September 2026, and shows the Quartz cron syntax for the advanced one.
Trigger type: Scheduled
Schedule type: Advanced
Cron: 0 0 * * * ? (every hour, on the hour)
Time zone: UTCChoose UTC. Databricks notes that hourly jobs in a daylight saving zone can be skipped or delayed when the clock changes. Fifty rows an hour is 1,200 rows a day. Raise the limit for a larger backlog.
Step 5: Read the results
Query the table. Only some email_status values are safe to mail.
SELECT id, work_email, email_status, phone, enrich_status
FROM main.sales.leads
WHERE enrich_status = 'matched'
AND email_status IN ('verified', 'catch_all_valid', 'catch_all');Drop invalid, spam_trap, abuse and disposable. LeadOcean gives no refund for bounced emails, so filter before you send.
What you get
The email_status list the query relies on comes from leadocean_list_enum_values, a free call. We ran it on 2026-10-21.
{"name":"email_status","total":13,"matched":13,"values":["verified","catch_all_valid","catch_all","risky","unknown","untested","invalid","role","disposable","spam_trap","abuse","derived","none"],"truncated":false}Each matched person returns this shape. It is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"profile_data": { "...": "name, title, company" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_emails": [{ "email": "jane.doe@acme.com", "type": "work" }],
"contact_phones": [{ "phone": "+14155550133", "type": "mobile" }]
}
}
}Person enrich also returns career history in resume_data when held: past roles, education and certifications. The notebook does not store it. Add it to profile if you want it.
What it costs
One record is one person or one company you receive. A matched people enrich is 1 record, and a matched company enrich is 1 record. A 404, a count and an empty page cost nothing.
On Free, the 1,000 records are one-off, so the hourly job stops with a 402 once they are spent. On a paid plan there are no credits. Solo is $97 a month and Agency is $497 a month, each with a fair-use pace instead of a balance. Reaching the pace slows the key until the window resets. See pricing.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | The x-api-key header is missing or the key is wrong. | Run databricks secrets put-secret again with a fresh key. |
| 402 | Your records are spent. Free is 1,000, one-off. | Check GET /v1/account, or compare plans on pricing. |
| 404 | We hold nothing for that identifier. | Nothing to fix. It costs 0 and the row is marked not_found. |
| 429 | Requests came faster than the plan's rate limit: 5 a second on Free, 200 on Agency. | Wait the Retry-After seconds. The notebook does, and sends one request at a time. |
| Rows never update | The job ran but failed before the merge, or enriched_at is already set. | Open the run output in the Jobs page and check the printed message. |
| Empty search results | A search filter used a value that is not an exact enum string. | Call leadocean_list_enum_values for the exact string. |
FAQ
Can I enrich by name and company domain?
Not directly. Enrich takes exactly one of linkedin_url, email, phone or person_id. Search by domain first, then enrich by person_id.
Will the job re-spend records on rows it already enriched?
No. It only selects rows where enriched_at is null, and the merge sets that column on a match and on a 404.
How do I size a company list before I spend anything?
Use GET /v1/companies/count or the leadocean_count_companies MCP tool. Both are free and return an exact total.
Can I run it over a very large table?
Raise the batch limit and the schedule together, and stay inside your plan's pace. For a one-off bulk pull, POST /v1/exports takes up to 50,000 rows a request. Bulk CSV export is not on Free.
Do other warehouses work the same way?
Enrich your Databricks rows on a schedule, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →