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

How-to guide

How to Enrich a Databricks Table With LeadOcean (Scheduled Notebook Job)

A scheduled job picks up new rows in a Delta table, calls LeadOcean enrich, and merges the result back into the same rows. One record per matched row.

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

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_url or email on 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_KEY in your shell. It never goes in a notebook or in Git.
bash
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.

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

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

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

text
Trigger type:  Scheduled
Schedule type: Advanced
Cron:          0 0 * * * ?     (every hour, on the hour)
Time zone:     UTC

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

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

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

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

ErrorCauseFix
401The x-api-key header is missing or the key is wrong.Run databricks secrets put-secret again with a fresh key.
402Your records are spent. Free is 1,000, one-off.Check GET /v1/account, or compare plans on pricing.
404We hold nothing for that identifier.Nothing to fix. It costs 0 and the row is marked not_found.
429Requests 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 updateThe 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 resultsA 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?

The call is identical. See Snowflake, Postgres and BigQuery. More recipes are in the blog hub.

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 →