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

How-to guide

How to Enrich a CSV of Contacts (in Windsurf)

Add one MCP entry, pilot 25 rows in chat, then run the whole file with a short script. Rows we hold nothing for cost nothing.

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

TL;DR

  • Add the LeadOcean MCP server to Windsurf, test 25 rows in chat, then run the full file with a Python script against POST /v1/people/enrich.
  • It takes about 20 minutes. Each person we match spends one record. A person we hold nothing for is a 404 and costs nothing.
  • A 200-row file spends at most 200 of your 1,000 free records. Your real number depends on how many rows match.

Prerequisites

  • A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Its keys get both the search and enrich scopes.
  • An API key in your shell as $LEADOCEAN_API_KEY. The chat steps use OAuth and need no key. The script step does.
  • Windsurf, Python 3 and curl.
  • A CSV with one identifier per row: an email or a linkedin_url. Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus company lookup.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"

Step 1: Connect the LeadOcean MCP server

Add one remote entry to Windsurf's MCP config and sign in through your browser. No key goes into the file.

For the legacy Cascade agent, open the ... (Actions) menu in the Cascade panel, then the Open MCP config file icon in the MCPs section. Add this under mcpServers and save. The Windsurf docs, October 2026, say remote HTTP servers need a serverUrl or url field.

json
{
  "mcpServers": {
    "leadocean": {
      "serverUrl": "https://api.leadocean.io/mcp"
    }
  }
}

For the Devin Local agent, the Devin CLI docs, October 2026, add a remote server with one command.

bash
devin mcp add leadocean https://api.leadocean.io/mcp

Cascade caps you at 100 tools, and LeadOcean adds 13. The full walkthrough, with sign-in and troubleshooting, is on Connect LeadOcean to Windsurf.

Step 2: Put your contacts in a CSV

Keep the file plain. Use a header row, one person per row, and no commas inside fields. The identifier column is what we match on. Name and company are for your own checking.

csv
first_name,last_name,company,email,linkedin_url
Jane,Doe,Acme,jane.doe@acme.com,
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-example

Save it as contacts.csv in the project folder you opened in Windsurf. These rows are placeholders. Put your own first 25 rows in pilot.csv and keep them out of contacts.csv, so you never enrich the same person twice.

Step 3: Pilot 25 rows in chat

Run a small batch first. The pilot shows your match rate and whether your identifiers work, before you commit the whole file.

text
Read pilot.csv. For each row, call leadocean_get_lead with the email, or the
linkedin_url when there is no email. Stop after 25 rows. Write pilot_enriched.csv
with the original columns plus job title, company, email_status and has_phone.
Skip rows with no identifier. Do not call any other LeadOcean tool that spends
records. At the end, tell me how many rows matched and how many returned nothing.

A leadocean_get_lead call that finds nobody costs nothing, the same as the REST 404. Every match costs one record, so 25 rows spend at most 25. Devin Local can ask for approval before an MCP tool call. Its permissions config can pre-approve leadocean_get_lead.

Chat suits a few dozen rows. Past that, each row is a model turn, and a script is faster and cheaper.

Read the output file before you go on. Check the match rate first: if most rows came back empty, your identifiers are the problem, not the data. Then read the email_status column to see how much of the file is safe to send. Fix the CSV, not the prompt, if rows failed.

Step 4: Run the full file with a script

Send one enrich request per row. Set reveal_email to true to get the addresses and phones we hold, at no extra record. The script writes enriched.csv and stops on a 402, so it never loops against an empty balance.

python
import csv, json, os, time, urllib.request, urllib.error

KEY = os.environ["LEADOCEAN_API_KEY"]
URL = "https://api.leadocean.io/v1/people/enrich"

def enrich(body):
    req = urllib.request.Request(
        URL, data=json.dumps(body).encode(), method="POST",
        headers={"x-api-key": KEY, "content-type": "application/json"})
    try:
        with urllib.request.urlopen(req) as r:
            return r.status, json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 429:
            time.sleep(int(e.headers.get("Retry-After", "1")))
            return enrich(body)
        return e.code, None

with open("contacts.csv", newline="") as f, open("enriched.csv", "w", newline="") as g:
    rows = csv.DictReader(f)
    out = csv.DictWriter(g, fieldnames=rows.fieldnames + [
        "result", "email_status", "best_email", "best_phone"])
    out.writeheader()
    matched = 0
    for row in rows:
        body = {"reveal_email": True}
        if row.get("email"):
            body["email"] = row["email"]
        elif row.get("linkedin_url"):
            body["linkedin_url"] = row["linkedin_url"]
        else:
            out.writerow({**row, "result": "no_identifier"})
            continue
        code, data = enrich(body)
        if code == 402:
            print("Out of records. Stopping.")
            break
        if code != 200:
            out.writerow({**row, "result": "not_found" if code == 404 else f"http_{code}"})
            continue
        matched += 1
        c = data["data"].get("contact_data", {})
        emails, phones = c.get("contact_emails") or [], c.get("contact_phones") or []
        out.writerow({**row, "result": "matched",
                      "email_status": c.get("email_status") or "",
                      "best_email": emails[0]["email"] if emails else "",
                      "best_phone": phones[0]["phone"] if phones else ""})
    print(f"Matched {matched} rows. Records spent: {matched}.")

Save it as enrich.py and run python3 enrich.py in the Windsurf terminal. The final count it prints is your record cost.

The script sends one request at a time, so it stays well under the 5 requests a second Free allows. It writes a row for every input, so a failed run still leaves a file you can compare with contacts.csv. To resume after a 402, remove the finished rows from contacts.csv and run it again.

Step 5: Check the cost, then check the statuses

GET /v1/account is free. It returns records used and remaining, so you can compare it with the script's count.

bash
curl -s https://api.leadocean.io/v1/account \
  -H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records | {used, remaining}'

Then filter on email_status before you send anything. Read the status on the enriched record, not on a search row. Search counts verified more loosely, so the enriched answer is the correct one. Do not send to invalid, spam_trap, abuse or disposable. Bounces are not refunded.

What you get

The output is enriched.csv: your original columns plus result, email_status, best_email and best_phone. Rows with no match say not_found and spent nothing. This is the shape of two rows, with placeholder values.

csv
first_name,last_name,company,email,linkedin_url,result,email_status,best_email,best_phone
Jane,Doe,Acme,jane.doe@acme.com,,matched,verified,jane.doe@acme.com,+14155550133
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-example,not_found,,,

Each enrich call returns this response shape from the API reference, with placeholder values. The contact_emails and contact_phones arrays appear only when reveal_email is true.

json
{
  "success": true,
  "data": {
    "profile_data": { "...": "name, headline, current role, company" },
    "contact_data": {
      "has_email": true,
      "has_phone": true,
      "email_status": "verified",
      "email_type": "work",
      "contact_emails": [
        { "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
      ],
      "contact_phones": [
        { "phone": "+14155550133", "type": "mobile", "priority": 1 }
      ]
    }
  },
  "meta": { "source": "leadocean", "key_hash": "<cache key>" }
}

Statuses are exact strings. The free leadocean_list_enum_values tool returned these 13 for email_status on 2026-10-21.

json
{
  "name": "email_status",
  "total": 13,
  "values": ["verified","catch_all_valid","catch_all","risky","unknown","untested",
             "invalid","role","disposable","spam_trap","abuse","derived","none"]
}

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked key.Copy the key again from app.leadocean.io and re-export $LEADOCEAN_API_KEY.
402The free plan's 1,000 records are spent. They do not reset.Check GET /v1/account. Move to Solo at $97 a month, or Agency at $497 a month for a team. Paid plans have a fair-use pace, not credits.
429Above the plan's rate limit (5 requests a second on Free, 50 on Solo, 200 on Agency).Wait the Retry-After seconds. The script above already does, and runs one request at a time.
404 on a rowWe hold nothing for that identifier.Nothing to fix and no record spent. Try the other identifier, or search by domain and enrich by person_id.
No leadocean tools in CascadeThe config entry uses the wrong field. Remote servers need serverUrl or url.Check the URL is exactly https://api.leadocean.io/mcp and reopen the MCPs section.
Empty or rejected filters in chatA value is not in the catalogue, such as a guessed industry.Have the agent call leadocean_list_enum_values and use the exact string.

FAQ

How many records does a CSV enrichment cost?

One per row we match. A row we hold nothing for is a 404 and costs nothing. A 200-row file therefore spends at most 200 records. Read the script's final count for the real number.

Can I enrich with only a name and a company?

No. Enrich needs one of linkedin_url, email, phone or person_id. With a name and a domain, search by domain first with /v1/people/search, then enrich the person you want by person_id. Search spends one record per person returned.

Should I use Cascade chat or the script?

Use chat for a pilot of a few dozen rows, where you want to read the result. Use the script for anything larger. The script is deterministic and runs at API speed, and chat needs no API key.

Does the free plan work for this?

Yes. Free is 1,000 records, one-off, with the same API and MCP server as the paid plans. Solo is $97 a month and Agency is $497 a month.

How fresh is the data I get back?

The dataset is refreshed monthly. Each record also carries its own fetched_at date, so check it before you rely on a job title for a campaign.

Other editors have the same recipe: Cursor, VS Code and ChatGPT. The MCP hub lists every client.

Enrich your first 25 contacts from Windsurf, free

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →