You can fill the email and phone columns of a monday CRM leads board with one Node script. It reads rows that have a LinkedIn URL and no email, calls LeadOcean once per row, and writes the result back. No automation vendor is involved, so there is nothing else to pay for.
This is the version of "monday CRM enrichment tools" that a technical buyer can read in one sitting. Every call below is a real request. Nothing here describes a tool our team uses, because the point is the recipe.
TL;DR
- Time: about 30 minutes to build and test, then a run takes seconds per 50 rows.
- 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: monday caps API calls per day by plan, 1,000 a day on Free, Standard and Basic, 10,000 on Pro (monday rate limits, October 2026).
Prerequisites
- A monday board with these columns: a Link column holding the LinkedIn URL, an Email column, a Phone column and a Text column for the email status. Note each column ID.
- A monday personal API token from the Developer Center (monday authentication, October 2026). Export it as
$MONDAY_TOKEN. - A LeadOcean key from app.leadocean.io/register. Export it as
$LEADOCEAN_API_KEY. - Node 18 or later, for the built-in
fetch.
Step 1: Test the LeadOcean call
Prove the key works before you build anything. One identifier per call: linkedin_url, email, phone or person_id. Set reveal_email to true to get the address and numbers 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 2: Pull the rows that need enrichment
monday filters items server side. Ask for items whose email column is empty, and read the LinkedIn URL from the Link column. The Link column exposes url on LinkValue (monday link column, October 2026). The email filter operator is_empty takes an empty array (monday email column, October 2026).
query {
boards(ids: 1234567890) {
items_page(
limit: 50
query_params: { rules: [{ column_id: "email", compare_value: [], operator: is_empty }] }
) {
cursor
items {
id
name
column_values(ids: ["linkedin"]) {
... on LinkValue { url }
}
}
}
}
}Replace 1234567890 with your board ID, and email and linkedin with your column IDs. monday paginates with a cursor and next_items_page. A cursor is valid for 60 minutes (monday board items, October 2026).
Step 3: Write the script
The script sends the query, enriches each row, and writes back with change_multiple_column_values. The email column needs both email and text keys. The phone column takes phone and countryShortName (monday phone column, October 2026).
// enrich-monday.mjs (node enrich-monday.mjs)
const BOARD = 1234567890;
const COL = { link: "linkedin", email: "email", phone: "phone", status: "email_status" };
const DEFAULT_COUNTRY = "US"; // the phone column needs a country code
async function monday(query, variables) {
const r = await fetch("https://api.monday.com/v2", {
method: "POST",
headers: { Authorization: process.env.MONDAY_TOKEN, "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const j = await r.json();
if (j.errors) throw new Error(JSON.stringify(j.errors));
return j.data;
}
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;
}
const rows = await monday(`query($b:[ID!]){ boards(ids:$b){ items_page(limit:50,
query_params:{rules:[{column_id:"${COL.email}",compare_value:[],operator:is_empty}]}){
items{ id name column_values(ids:["${COL.link}"]){ ... on LinkValue { url } } } } } }`, { b: [BOARD] });
for (const item of rows.boards[0].items_page.items) {
const url = item.column_values[0]?.url;
if (!url) continue;
const p = await enrich(url);
const email = p?.contact_data?.contact_emails?.[0];
if (!email?.email) continue;
const phone = p.contact_data.contact_phones?.[0]?.phone;
const values = {
[COL.email]: { email: email.email, text: item.name },
[COL.status]: email.status ?? "",
...(phone && { [COL.phone]: { phone, countryShortName: DEFAULT_COUNTRY } }),
};
await monday(`mutation($b:ID!,$i:ID!,$v:JSON!){ change_multiple_column_values(
board_id:$b,item_id:$i,column_values:$v){ id } }`, { b: BOARD, i: item.id, v: JSON.stringify(values) });
console.log(item.id, email.status);
}The script runs rows one at a time. That stays far below the Free plan's 5 requests a second on the LeadOcean side. It does use two monday calls per row, so check your daily call limit before you point it at a large board.
Step 4: Gate the write on email status
Do not push every address into the CRM as sendable. LeadOcean returns an email_status per address, with 13 possible values. Treat verified and catch_all_valid as mailable. Hold everything else for review.
Add this before the mutation:
if (!["verified", "catch_all_valid"].includes(email.status)) {
console.log(item.id, "skipped", email.status);
continue;
}We do not refund bounced emails, so read the status column before any send.
Step 5: Size the audience for new leads
Enrichment fixes rows you already hold. To add new rows, 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: 51-200
total: 18,567 (verified, catch_all_valid or catch_all)
totalAll: 60,456 (no email condition)
distinctCompanies: 12,192The 18,567 is a mailable count: people whose best email is verified, catch_all_valid or catch_all. To load them into monday, pull a CSV, then use monday's own board import. Use the Exports page in the LeadOcean app (filter builder, column picker, price preview, download) or call POST /v1/exports. Bulk CSV export is not on Free.
Step 6: Run it on a schedule
Run the script on a timer so new rows fill themselves. A cron line on any box with Node does it. Hourly is plenty for a leads board.
0 * * * * MONDAY_TOKEN=... LEADOCEAN_API_KEY=... node /opt/enrich/enrich-monday.mjs >> /var/log/enrich.log 2>&1Keep the tokens in a secrets store, not in the crontab. Each run only touches rows whose email column is empty, so a row that is already filled costs nothing on the next pass. A row LeadOcean could not match stays empty and is retried each hour as a free 404. Add a "tried" status column and filter on 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. Pick the one your team already runs: Make, Zapier or n8n. For a one-off backfill the script is faster, and for a trickle of new rows the automation tool is easier to hand over.
What you get
A run on three placeholder rows prints one line per row. Your item IDs differ:
5550000001 verified
5550000002 catch_all_valid
5550000003 skipped riskyThe script reads this LeadOcean response. It is the response shape from the API reference, with placeholder values:
{
"success": true,
"data": {
"profile_data": { "...": "Jane Doe, VP Sales at Acme" },
"contact_data": {
"has_email": true,
"has_phone": true,
"contact_emails": [{ "email": "jane.doe@acme.com", "type": "work", "status": "verified" }],
"contact_phones": [{ "phone": "+14155550133" }],
"contact_current_experiences": [{ "...": "VP Sales at Acme (acme.com)" }]
}
}
}Since 17 October 2026 the same call also returns career history in resume_data. That covers 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 rows one at a time or add a short delay. 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 row. |
| monday returns an error on the mutation | The value shape does not match the column type | Send email plus text for Email, phone plus countryShortName for Phone (monday docs, October 2026). |
| monday rate or daily call limit error | Too many calls for your monday plan | Run smaller batches. The daily limit resets at midnight UTC (monday rate limits, October 2026). |
FAQ
Does LeadOcean have a native monday app?
Can I enrich by email instead of LinkedIn URL?
Yes. Send email in place of linkedin_url. Send exactly one identifier per call. Two identifiers return a 400.
How many records does a 1,000-row board cost?
At most 1,000, one per person found. Rows with no match are free. The Free plan covers one 1,000-row pass, then you need a paid plan.
Does this replace a CRM enrichment tool?
It replaces the lookup. It does not give you a rep workspace or a sequencer, because LeadOcean has neither. For the wider comparison read best enrichment APIs for B2B sales teams and what CRM enrichment adds to your sales data.
Where do I start if my CRM is messy?
Start with the narrow pass in this guide, then read the best way to enrich CRM data.
Fill your monday leads board with a free LeadOcean key.
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →