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-afterheader (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
urlslist. - 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.
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.
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).
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).
// 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:
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: 0The 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.
0 * * * * CLOSE_API_KEY=... LEADOCEAN_API_KEY=... node /opt/enrich/enrich-close.mjs >> /var/log/enrich.log 2>&1Keep 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.
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:
cont_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA verified
cont_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB catch_all_valid
cont_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC skippedThe script reads this LeadOcean response. It is the response shape from the API reference, with placeholder values:
{
"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
| Error | Cause | Fix |
|---|---|---|
| 401 from LeadOcean | Key missing or wrong | Check $LEADOCEAN_API_KEY and the x-api-key header name. |
| 429 from LeadOcean | Over the plan's rate limit, 5 requests a second on Free, 200 on Agency | Process contacts one at a time. Honour Retry-After. |
| 402 from LeadOcean | The 1,000 free records are spent | Move to Solo at $97 a month. See pricing. |
| 404 from LeadOcean | No record for that person | Expected. No charge. The script skips the contact. |
| Empty results from a LeadOcean search | A filter value is not an exact enum string, such as vp for jobLevel | Resolve values with the free leadocean_list_enum_values tool or GET /v1/enums. |
| 401 or 429 from Close | Wrong key, or a request limit hit | Send 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?
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 →