You’re going to take a CSV export of CRM leads, look up each company with the Company Lookup API, and write back a CSV with the legal name, ticker, industry code, HQ address, phone and fiscal year end filled in. It’s plain Node.js, about 100 lines, and it handles the annoying parts: ambiguous names, duplicates, 404s and rate limits.
Fair warning up front: the data comes from SEC EDGAR filings, so it covers roughly 10,000 SEC-registered US public companies. A seed-stage startup or a local agency won’t be in there. The script treats that as a result, not a failure.
Look up a company with one API request
Swap in your key and run this:
curl "https://api.apiverve.com/v1/companylookup?ticker=AAPL" \
-H "x-api-key: YOUR_API_KEY"
Trimmed response:
{
"status": "ok",
"error": null,
"data": {
"count": 1,
"companies": [
{
"ticker": "AAPL",
"cik": "0000320193",
"name": "Apple Inc.",
"exchanges": ["Nasdaq"],
"entityType": "operating",
"sic": "3571",
"sicDescription": "Electronic Computers",
"category": "Large accelerated filer",
"stateOfIncorporation": "CA",
"fiscalYearEnd": "0926",
"phone": "(408) 996-1010",
"addresses": {
"business": {
"street1": "ONE APPLE PARK WAY",
"street2": null,
"city": "CUPERTINO",
"stateOrCountry": "CA",
"zipCode": "95014"
}
}
}
]
}
}
Two things to notice. Results always arrive in a companies array, even for a single ticker. And sicDescription, sector and formerNames are premium fields: on the free plan they come back as null. Everything else in that response is free, so the script below builds on sic and treats the description as a bonus.
The same endpoint also takes cik=320193 for a CIK lookup, search=Apple for a name search, and sic=3571 to list companies in an industry. We’ll use ticker and search.
Step 1: Set up the project
Node 18 or newer gives you fetch built in. The only dependencies are for CSV parsing:
mkdir crm-enrich && cd crm-enrich
npm init -y
npm install csv-parse csv-stringify
Here’s the shape of leads.csv. Most CRM exports have a company name; some have a ticker if someone bothered to fill it in:
email,company,ticker
jordan@apple.com,Apple Inc.,
sam@example.com,,AAPL
casey@smallshop.io,Smallshop Studio,
Step 2: Call the API with retries
Put this in companylookup.mjs. It returns data on success, null on a 404 (company not in SEC data), and backs off on 429:
const BASE = 'https://api.apiverve.com/v1/companylookup';
const API_KEY = process.env.APIVERVE_API_KEY;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export async function companyLookup(params, attempt = 0) {
const res = await fetch(`${BASE}?${new URLSearchParams(params)}`, {
headers: { 'x-api-key': API_KEY },
});
const body = await res.json().catch(() => ({}));
if (res.ok && body.status === 'ok') return body.data;
if (res.status === 404) return null;
if (res.status === 429 && attempt < 4) {
await sleep(1000 * 2 ** attempt);
return companyLookup(params, attempt + 1);
}
throw new Error(`companylookup ${res.status}: ${body.error ?? 'unknown error'}`);
}
The retry cap matters. A 429 means either you’re going too fast or you’re out of credits, and no amount of waiting fixes the second one. Four tries, then fail loudly. The error handling docs list the status codes.
Step 3: Match a lead to exactly one company
Tickers are easy: one ticker, one company. Names are where enrichment goes wrong, so be strict about it.
Name search is a prefix match, so search=Apple returns every company whose name starts with “Apple”. The search results are a summary (ticker, cik, name, sic, exchange), so once you’ve picked a match you fetch the full record by ticker. Create resolve.mjs:
import { companyLookup } from './companylookup.mjs';
const SUFFIXES = /\b(inc|incorporated|corp|corporation|co|company|ltd|llc|plc|holdings)\b/g;
const normalize = (s) =>
s.toLowerCase().replace(/[.,]/g, ' ').replace(SUFFIXES, '').replace(/\s+/g, ' ').trim();
export async function resolveCompany({ company, ticker }) {
if (ticker?.trim()) {
const data = await companyLookup({ ticker: ticker.trim() });
return data ? { match: 'ticker', record: data.companies[0] } : { match: 'not_found' };
}
if (!company || company.trim().length < 2) return { match: 'skipped' };
const hits = await companyLookup({ search: company.trim() });
if (!hits) return { match: 'not_found' };
const wanted = normalize(company);
const exact = hits.companies.filter((c) => normalize(c.name) === wanted);
const byCik = new Map(exact.map((c) => [c.cik, c]));
if (byCik.size !== 1) {
const note = hits.companies.slice(0, 5).map((c) => `${c.ticker}:${c.name}`).join(' | ');
return { match: 'ambiguous', note };
}
const full = await companyLookup({ ticker: [...byCik.values()][0].ticker });
return full ? { match: 'name', record: full.companies[0] } : { match: 'not_found' };
}
Deduping exact matches by cik handles companies with more than one share class. Two tickers, same CIK, same company.
Don’t be tempted to auto-pick the top result when nothing matches exactly. “Acme” prefix-matching “Acme United Corp” will happily attach a public company’s HQ address to a private lead with a similar name, and wrong data in a CRM is worse than empty data. Ambiguous rows get the top candidates in a note so a human can pick in ten seconds.
One more gotcha: the prefix match is case-sensitive (the API also tries your term uppercased). “Apple” works; “apple inc” typed in lowercase by a rep might not. If your CRM data is messy, title-case the name before searching.
Step 4: Map SEC fields onto CRM columns
SEC data is shouty (CUPERTINO) and the fiscal year end is MMDD. Clean both on the way in. Create map.mjs:
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const titleCase = (s) => (s ?? '').toLowerCase().replace(/\b\w/g, (ch) => ch.toUpperCase());
export function toCrmFields(c) {
const hq = c.addresses?.business ?? {};
const fye = c.fiscalYearEnd;
return {
legal_name: c.name,
ticker: c.ticker,
cik: c.cik,
exchange: (c.exchanges ?? []).join(' / '),
sic_code: c.sic ?? '',
industry: c.sicDescription ?? '', // premium field: null on the free plan
hq_street: titleCase([hq.street1, hq.street2].filter(Boolean).join(', ')),
hq_city: titleCase(hq.city),
hq_state: hq.stateOrCountry ?? '',
hq_zip: hq.zipCode ?? '',
phone: c.phone ?? '',
incorporated_in: c.stateOfIncorporation ?? '',
fiscal_year_end: fye ? `${MONTHS[Number(fye.slice(0, 2)) - 1]} ${Number(fye.slice(2))}` : '',
filer_category: c.category ?? '',
};
}
Rename the keys to match your CRM’s import template. HubSpot, Salesforce and Pipedrive all let you map CSV columns to properties on import, so exact names don’t matter much.
Step 5: Enrich the CSV with a per-company cache
A 5,000-lead export might mention a few hundred distinct companies. Look each one up once. Create enrich.mjs:
import { readFile, writeFile } from 'node:fs/promises';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';
import { resolveCompany } from './resolve.mjs';
import { toCrmFields } from './map.mjs';
const leads = parse(await readFile('leads.csv'), { columns: true, skip_empty_lines: true });
const cache = new Map();
const rows = [];
for (const lead of leads) {
const key = (lead.ticker || lead.company || '').trim().toUpperCase();
if (!cache.has(key)) {
try {
cache.set(key, await resolveCompany(lead));
} catch (err) {
cache.set(key, { match: 'error', note: err.message });
}
await new Promise((r) => setTimeout(r, 300));
}
const result = cache.get(key);
rows.push({
...lead,
match_status: result.match,
match_note: result.note ?? '',
...(result.record ? toCrmFields(result.record) : {}),
});
}
const columns = [...new Set(rows.flatMap(Object.keys))];
await writeFile('leads-enriched.csv', stringify(rows, { header: true, columns }));
const tally = {};
for (const r of rows) tally[r.match_status] = (tally[r.match_status] ?? 0) + 1;
console.log(tally);
Run it:
APIVERVE_API_KEY=your_key node enrich.mjs
Every row gets a match_status: ticker, name, ambiguous, not_found, skipped or error. Filter on it after import. not_found is useful on its own, since a company that isn’t SEC-registered is almost certainly private, which changes how you sell to it.
The 300ms pause keeps a single worker well under the free plan’s 5 requests per second, even though a name match makes two calls. Every call is billed, which is another reason to put tickers in your CRM when you have them: a ticker match is one call, a name match is two. Background on pacing is in API rate limits explained.
What company lookup enrichment is good for (and what it isn’t)
Dedupe accounts on cik, not name. “Apple”, “Apple Inc.” and a lead entered only as AAPL all resolve to CIK 0000320193. Group on it before you merge records.
Don’t expect brands to resolve. Search matches the legal name registered with the SEC. A lead that says “Google” won’t find Alphabet Inc. by prefix. That’s the ambiguous or not-found pile, and it’s a human job.
Fiscal year end is the sleeper field. If you sell to finance or IT teams, knowing that a prospect’s year closes in late September tells you when budgets get set. Apple’s 0926 is a good example of a year that doesn’t end in December.
Legal name and state of incorporation belong on contracts. Your proposal template wants “Apple Inc.”, incorporated in CA, not whatever a rep typed.
Re-run it periodically. Companies move HQs, change names and reclassify industries, and the dataset is rebuilt from SEC submissions. A quarterly re-enrichment of active accounts catches most of it.
Next steps
- Add the full SEC picture for your best accounts: Earnings Report returns revenue, net income, EPS, balance sheet and cash flow by ticker.
- Turn exchange names into full venue details (location, website) with the Stock Exchange API.
- Validate the contact side before enriching the company side. The lead enrichment pipeline guide covers emails and IPs, and apiverve/lead-validator-nextjs-tutorial is a working email and phone validation app.
- New to the platform? The quickstart covers keys and response format.
Get a free API key and run the script against your own export.