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

How-to guide

How to Enrich Close CRM Contacts (With the Close API and LeadOcean)

Find Close contacts with a LinkedIn URL and no email, look each one up, write the address and phone back. About 30 minutes, 1,000 records free.

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

You can fill missing emails and phones on Close contacts with one Node script. It lists contacts that have a LinkedIn URL and no email, calls LeadOcean once per contact, and updates the contact. There is no automation vendor in the middle.

Searches for Close enrichment tools return roundups of eight or ten products. This is the version a technical buyer can bookmark: one recipe, real requests, real output. For the tool comparison, read best enrichment tools for Close.

TL;DR

  • Time: about 30 minutes to build and test.
  • Cost: 1 LeadOcean record per person found. A person we hold nothing for is a 404 and costs nothing. The Free plan has 1,000 records for life, no card.
  • Limit to watch: Close rate limits per endpoint group and per API key. On a 429, wait for the time in the retry-after header (Close rate limits, October 2026).

Prerequisites

  • A Close account and an API key from Settings, Developer, API Keys (Close authentication, October 2026). Export it as $CLOSE_API_KEY.
  • A LeadOcean key from app.leadocean.io/register. Export it as $LEADOCEAN_API_KEY.
  • Close contacts that carry a LinkedIn URL in their urls list.
  • Node 18 or later, for the built-in fetch.

Step 1: Test the Close key

Close uses HTTP Basic auth. The API key is the username and the password is empty, so the trailing colon matters.

bash
curl https://api.close.com/api/v1/me/ -u "$CLOSE_API_KEY:"

A 200 with your user record means the key works. A 401 means the key is wrong or deleted.

Step 2: Test the LeadOcean call

Prove the second key before you build anything. Send exactly one identifier: linkedin_url, email, phone or person_id. Set reveal_email to true to get the address back.

bash
curl -X POST https://api.leadocean.io/v1/people/enrich \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"linkedin_url":"https://www.linkedin.com/in/jane-doe","reveal_email":true}'

reveal_email costs no extra record. Without it the response only says whether an email or phone exists (has_email, has_phone).

Step 3: List the contacts that need data

GET /api/v1/contact/ returns 100 contacts a page by default and a has_more flag. Page with _skip, and trim the payload with _fields (Close list contacts, October 2026).

bash
curl "https://api.close.com/api/v1/contact/?_limit=100&_skip=0&_fields=id,name,emails,phones,urls" \
  -u "$CLOSE_API_KEY:"

Each contact carries emails (email, type), phones (phone, type) and urls (url, type). The script keeps contacts whose emails list is empty and whose urls hold a linkedin.com/in/ link. Close also has an Advanced Filtering endpoint, POST /api/v1/data/search/, if you would rather filter server side.

Close paginates with _skip and has_more, so the loop below stops on its own. Reading contacts is free on the LeadOcean side, because only the enrich call spends a record. If your account holds tens of thousands of contacts, filter server side first so the script does not read every page on each run.

Step 4: Write the script

The script pages through contacts, enriches each match, and sends PUT /api/v1/contact/{id}/. Close takes phone numbers in E.164 format, so normalize any number that is not (Close update contact, October 2026).

js
// enrich-close.mjs  (node enrich-close.mjs)
const CLOSE = "https://api.close.com/api/v1";
const closeAuth = "Basic " + Buffer.from(process.env.CLOSE_API_KEY + ":").toString("base64");
const MAILABLE = ["verified", "catch_all_valid"];

async function close(path, opts = {}) {
  for (;;) {
    const r = await fetch(CLOSE + path, {
      ...opts,
      headers: { Authorization: closeAuth, "Content-Type": "application/json" },
    });
    if (r.status === 429) {
      const wait = Number(r.headers.get("retry-after") ?? 1);
      await new Promise((ok) => setTimeout(ok, wait * 1000));
      continue;
    }
    if (!r.ok) throw new Error(`Close ${r.status} ${path}`);
    return r.json();
  }
}

async function enrich(linkedinUrl) {
  const r = await fetch("https://api.leadocean.io/v1/people/enrich", {
    method: "POST",
    headers: { "x-api-key": process.env.LEADOCEAN_API_KEY, "content-type": "application/json" },
    body: JSON.stringify({ linkedin_url: linkedinUrl, reveal_email: true }),
  });
  if (r.status === 404) return null; // nobody matched, no charge
  if (!r.ok) throw new Error(`LeadOcean ${r.status}`);
  return (await r.json()).data;
}

let skip = 0, more = true, limit = 50; // stop after 50 enrichments on the first run
while (more && limit > 0) {
  const page = await close(`/contact/?_limit=100&_skip=${skip}&_fields=id,name,emails,phones,urls`);
  for (const c of page.data) {
    const li = (c.urls ?? []).find((u) => u.url.includes("linkedin.com/in/"));
    if (!li || (c.emails ?? []).length || limit <= 0) continue;
    limit--;
    const p = await enrich(li.url);
    const e = p?.contact_data?.contact_emails?.find((x) => MAILABLE.includes(x.status));
    if (!e) { console.log(c.id, "skipped"); continue; }
    const phone = p.contact_data.contact_phones?.[0]?.phone;
    const body = { emails: [{ email: e.email, type: "office" }] };
    if (phone && !(c.phones ?? []).length) body.phones = [{ phone, type: "mobile" }];
    await close(`/contact/${c.id}/`, { method: "PUT", body: JSON.stringify(body) });
    console.log(c.id, e.status);
  }
  skip += 100;
  more = page.has_more;
}

The script only touches contacts with no email, so it never overwrites one. It skips any address that is not verified or catch_all_valid. We do not refund bounced emails, so read email_status before any send.

Step 5: Size a list for new leads

Enrichment fixes contacts you already hold. To add new ones, size the list first. leadocean_count_leads over the MCP server is free and returns an exact total. On 21 October 2026 this count ran against the live index:

text
jobLevel: VP
jobFunction: Sales & Business Development
country: US
employeeRange: 11-50

total: 20,836   (verified, catch_all_valid or catch_all)
totalAll: 69,766  (no email condition)
distinctCompanies: 16,102
credits: 0

The 20,836 is a mailable count: people whose best email is verified, catch_all_valid or catch_all. To load them into Close, export a CSV and use Close's own import. Use the Exports page in the LeadOcean app. It has a filter builder, a column picker, a price preview and a download button. Or call POST /v1/exports, which takes up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on Free.

Step 6: Run it on a schedule

A cron line on any machine with Node is enough. Hourly is plenty for contacts that arrive through forms or imports.

bash
0 * * * * CLOSE_API_KEY=... LEADOCEAN_API_KEY=... node /opt/enrich/enrich-close.mjs >> /var/log/enrich.log 2>&1

Keep the keys in a secrets store, not in the crontab. A contact that LeadOcean cannot match stays empty and is retried each run as a free 404. Add a Close custom field such as "enrichment tried" and skip contacts that have it if that bothers you.

If you would rather not host a script, the same two requests fit a no-code tool with an HTTP step. See Make, Zapier or n8n.

Step 7: Check what changed before you widen the run

Run the first pass on 50 contacts, then look at them in Close before you raise the limit. Three checks catch most problems.

First, confirm the email landed on the right contact. The script updates by contact ID, so it cannot create duplicates, but it can fill a stale contact whose LinkedIn URL points at someone who left. Second, count how many rows were skipped. A high skip rate means the LinkedIn URLs in Close are old or malformed, not that the lookup failed. Third, check the phone type. The script writes mobile for every number it adds. Adjust that line if your team dials by type.

To confirm a single contact by hand, fetch it with the same key.

bash
curl "https://api.close.com/api/v1/contact/cont_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/?_fields=id,name,emails,phones" \
  -u "$CLOSE_API_KEY:"

The first pass looks right? Then raise limit = 50 to the number you want to cover and rerun. Every run is safe to repeat, because it only selects contacts with an empty emails list.

What you get

A run on three placeholder contacts prints one line each. Your IDs differ:

text
cont_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA verified
cont_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB catch_all_valid
cont_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC skipped

The script reads this LeadOcean response. It is the response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": {
    "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme" },
    "contact_data": {
      "has_email": true,
      "has_phone": true,
      "contact_emails": [{ "email": "jane@acme.com", "type": "work", "status": "verified" }],
      "contact_phones": [{ "phone": "+14155550133", "type": "mobile" }],
      "contact_current_experiences": [{ "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }]
    }
  }
}

Since 17 October 2026 the same call also returns career history in resume_data: past roles, education and certifications. Past employers carry no company_id yet.

Troubleshooting

ErrorCauseFix
401 from LeadOceanKey missing or wrongCheck $LEADOCEAN_API_KEY and the x-api-key header name.
429 from LeadOceanOver the plan's rate limit, 5 requests a second on Free, 200 on AgencyProcess contacts one at a time. Honour Retry-After.
402 from LeadOceanThe 1,000 free records are spentMove to Solo at $97 a month. See pricing.
404 from LeadOceanNo record for that personExpected. No charge. The script skips the contact.
Empty results from a LeadOcean searchA filter value is not an exact enum string, such as vp for jobLevelResolve values with the free leadocean_list_enum_values tool or GET /v1/enums.
401 or 429 from CloseWrong key, or a request limit hitSend the key as the Basic username with an empty password. On a 429, wait for retry-after (Close rate limits, October 2026).

FAQ

Does LeadOcean have a native Close integration?

No. It is a REST API with a hosted MCP server. You call it from a script or from an automation tool that can make HTTP requests. The same request works in Make, Zapier and n8n.

Can I enrich by email instead of LinkedIn URL?

Yes. Send email in place of linkedin_url. Send exactly one identifier per call.

How many records does a 1,000-contact account cost?

At most 1,000, one per person found. Contacts with no match are free. The Free plan covers one 1,000-contact pass, then you need a paid plan.

Does this replace Close's own features?

No. Close keeps the calling, email sequences and pipeline. This only fills the data. LeadOcean has no dialer, sequencer or CRM. For the wider comparison, read best CRM enrichment tools and CRM enrichment.

Fill the empty fields in your Close contacts with a free LeadOcean key.

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

Get your free API key →