Open menu

Enriching LinkedIn URLs at Scale: An API Workflow

Enriching LinkedIn URLs at Scale: An API Workflow

A LinkedIn URL is a terrible thing to store in a CRM. It tells you almost nothing on its own.

You can’t segment on it. You can’t score it. You can’t email it. And yet most teams I’ve worked with have thousands of them sitting in a column, collected from events and form fills and exports, doing absolutely nothing.

So here’s how LinkedIn URL enrichment turns that dead column into real contact records, with two API calls, a URL cleaner, and a loop that can survive 10,000 rows.

What LinkedIn URL enrichment actually is

LinkedIn URL enrichment turns a profile URL into structured person and company data through an API lookup. You send the URL, you get back a name, a title, an employer, a location: fields you can actually filter, score, and route.

And it’s worth being precise about HOW that happens, because there are two very different mechanics hiding behind the word “enrichment.”

The first is live scraping: software logs into LinkedIn (or pretends to) and reads the profile page on demand. It feels fresh, but it runs against LinkedIn’s terms, it breaks whenever the page layout changes, and the accounts doing the scraping get restricted or banned with depressing regularity.

The second is database matching: the API matches your URL against an existing, continuously refreshed B2B database and returns the record it already holds. No login, no bot traffic, no account at risk. That’s the mechanic behind the endpoints in this guide: you’re querying a database, not puppeteering a browser.

Why does the distinction matter to you? Because the failure modes are different. A scraper fails loudly and gets you banned. A database match fails quietly; the record might lag a job change by a little while. You plan for the second kind. More on that in the failure section.

And there’s a clock ticking on all of this, which is the part people underestimate. Contact data rots. People change jobs, companies rename, titles shift; HubSpot’s long-standing estimate puts database decay at roughly 22.5% per year. A LinkedIn URL column you collected two years ago isn’t just useless as-is; the assumptions attached to it are quietly expiring. Enrichment is how you find out what’s still true.

The two endpoints you need

Before the API version, the “process” at every company I’ve seen was the same: an intern (or worse, a rep) opening profiles one by one, reading, retyping. Twenty profiles an hour on a good day, with typos. For a 3,000-row column that’s two working weeks of a human’s life. Hold that picture while we look at the alternative.

So which calls do you actually need? Two. They do different jobs, and picking the wrong one is the most common mistake I see:

Now here’s the part that surprises everyone, me included. You’d assume the full profile is the expensive call. It’s the opposite. The documented credit costs are 1 credit for the whole profile and 5 credits for the email.

📌 Credit shape: the full profile costs 1 credit; the verified email costs 5. Budget for the emails, not the profiles.

That flips the usual logic. “I’ll grab the profile too, since I’m here” is fine; it’s cheap. “I’ll grab the email for everyone, just in case” is the budget mistake. The decision rule I use:

  • Segmentation, scoring, routing: profile enrichment only
  • Outreach: email finder, on the segment you’ll actually contact
  • Both: only when a record genuinely needs the full picture AND a verified address

Start with the profile

from cufinder import Cufinder

client = Cufinder('your-api-key-here')

result = client.epp('linkedin.com/in/iain-mckenzie')
print(result)

One argument. The profile URL. That’s it.

And notice there’s no https:// on the front. The API takes the bare path, so strip your URLs before you send them or you’ll get avoidable misses. (The cleaner further down handles this for you.)

What comes back is a full person record nested under data.person. Here’s the documented field set, because this is the part you’ll map into your CRM later:

Field groupFields you get back
Identityfirst_name, last_name, full_name, linkedin_url, avatar, summary, followers_count
Rolejob_title, job_title_categories
Locationcountry, state, city
Companycompany_name, company_website, company_linkedin, company_size, company_industry, company_country, company_state, company_city
Socialsfacebook, twitter (when present)

Read that table again and notice what’s NOT in it: an email address. Profile Enrichment doesn’t return one, because email discovery is deliberately a separate endpoint with separate verification. If you take one thing from this section, take that, because it’s the number one “the API is broken” support question that isn’t.

The reference lists this endpoint at 93% confidence, and each response carries its own confidence_level field so you can store it alongside the data.

A couple of those fields deserve a second look, because they’re more useful than they sound. job_title_categories gives you a normalized read on what the person actually does, handy when the raw title is something creative like “growth wizard.” And followers_count is a quiet little influence signal: a contact with 20,000 followers is a different outreach conversation than one with 80.

What an enriched column lets you do

Let’s pause on the payoff for a second, because “structured data” is abstract until you see what it buys you.

Before enrichment, your column is 3,000 identical-looking URLs. After it, the same column answers real questions:

  • Segmentation: “Show me everyone at software companies with 51-200 employees in Germany.” That’s three enriched fields (company_industry, company_size, company_country), and suddenly your event list is a campaign list.
  • Scoring: weight title seniority, company size, and industry fit, and your sales team starts each Monday with a ranked queue instead of an alphabetical one.
  • Routing: geo fields send the Munich contacts to your DACH rep instead of a round-robin lottery.
  • Personalization: a first line that mentions the person’s actual role and company beats “Hi {first_name}” every single time.

None of that needed an email address, notice. That’s why the profile call comes first: most of the value of LinkedIn URL enrichment is in the fields, not the inbox. The email matters on the day you hit send. The fields matter every day in between.

Working in TypeScript instead? Same call, same shape:

import { Cufinder } from '@cufinder/cufinder-ts';

const client = new Cufinder('your-api-key-here');

const result = await client.epp('linkedin.com/in/iain-mckenzie');
console.log(result);

Official SDKs exist for TypeScript, Python, Go, Ruby, and Rust; the method names stay identical across all five.

Then the email, if you need it

email = client.fwe('linkedin.com/in/iain-mckenzie')

Same input, different output. The response is tiny and exactly what outreach needs:

{
    "status": 1,
    "data": {
        "confidence_level": 94,
        "query": "linkedin.com/in/iain-mckenzie",
        "work_email": "iain.mckenzie@stripe.com",
        "credit_count": 9787
    }
}

This one runs at 94% confidence, which means roughly one in sixteen will be wrong or missing. That’s normal for email discovery (nobody honest promises 100%), and it’s why you verify before a big send rather than trusting the column blindly. If email is your main deliverable, I wrote up the deeper patterns separately in automating LinkedIn email finding with Python.

Cleaning the URLs first

Your URLs are filthy. Here’s the cleaner that fixes them.

Nobody warns you about this part, but it decides your match rate before the first API call. Real-world LinkedIn columns have tracking parameters, trailing slashes, country subdomains, mobile prefixes, and half of them are the /pub/ format from 2015. Feed those in raw and you’ll blame the API for your own data.

import re

def clean_linkedin(url):
    if not url:
        return None
    u = url.strip().lower()
    u = u.split('?')[0].rstrip('/')          # drop tracking params
    u = re.sub(r'^https?://', '', u)          # drop scheme
    u = re.sub(r'^[a-z]{2}\.linkedin', 'linkedin', u)   # drop country subdomain
    u = u.replace('www.', '').replace('m.linkedin', 'linkedin')
    if '/in/' not in u:
        return None                            # company page, not a person
    return u

That last check matters more than it looks. Company page URLs get mixed into person columns constantly, and they’ll fail every single call until you filter them out.

One special case deserves its own callout: Sales Navigator links. If your column was exported from Sales Navigator, the URLs look like linkedin.com/sales/lead/…. That’s a different format entirely, it contains no /in/ path, and the cleaner above will (correctly) drop it. Those rows aren’t lost; they just need the person’s public profile URL instead, which Sales Navigator shows on the lead page. Fix the export, not the cleaner.

Two notes while you’re here. Custom vanity slugs (the ones people set through LinkedIn’s public profile URL settings) are perfectly fine inputs; the endpoint just takes the URL as it is. And deduplicate BEFORE you run anything, because repeated identical inputs are charged separately. Nothing you send is stored between requests, so the API has no way of knowing row 4,012 is the same person as row 117. Only you know that.

The bulk pipeline, with checkpoints

Clean URLs in, structured records out. Now let’s make it survive 10,000 rows.

The upgrade that matters at scale is the checkpoint file. A bulk LinkedIn profile enrichment run of 10,000 URLs takes hours, and SOMETHING will interrupt it: a dropped connection, a laptop lid, a deploy. Without a checkpoint, you restart from zero and pay again for everything you’d already enriched. With one, you resume exactly where you stopped.

I use JSON Lines for this, one record per line, appended as you go, unbreakable by a crash mid-run:

import csv, json, os, time
from cufinder import Cufinder

client = Cufinder('your-api-key-here')
CHECKPOINT = 'enriched.jsonl'

def already_done(path):
    done = set()
    if os.path.exists(path):
        with open(path) as f:
            for line in f:
                done.add(json.loads(line)['linkedin_url'])
    return done

def enrich_all(csv_path, want_email=True):
    done = already_done(CHECKPOINT)
    skipped = 0
    with open(csv_path) as f, open(CHECKPOINT, 'a') as out:
        for r in csv.DictReader(f):
            url = clean_linkedin(r.get('linkedin_url'))
            if not url:
                skipped += 1
                continue
            if url in done:
                continue                 # already paid for, never twice

            record = {'linkedin_url': url}
            try:
                record['profile'] = client.epp(url)
                if want_email:
                    record['email'] = client.fwe(url)
                record['status'] = 'ok'
            except Exception as e:
                record['status'] = f'failed: {e}'

            out.write(json.dumps(record) + '\n')
            done.add(url)
            time.sleep(0.7)    # two calls per row under the 100/min cap

    print(f'{len(done)} enriched, {skipped} skipped as unusable')

Two calls per row means you’re burning through your rate limit twice as fast, which is why the sleep is longer than you might expect.

The two-pass pattern: profiles first, emails second

Here’s the single biggest cost optimization in this whole workflow, and it falls straight out of that credit shape from earlier.

Don’t enrich emails for everyone. Run the cheap profile pass over the entire list first, at 1 credit each. Then segment: who actually fits your ICP? Who has a title you’d genuinely contact? That’s usually a fraction of the list. THEN run the 5-credit email pass over just that fraction:

# pass 1: profiles for everyone (1 credit each)
enrich_all('urls.csv', want_email=False)

# segment offline: keep the rows worth contacting
# pass 2: emails for the shortlist only (5 credits each)
def email_pass(shortlist_urls):
    for url in shortlist_urls:
        try:
            res = client.fwe(url)
            print(url, res.get('data', {}).get('work_email'))
        except Exception as e:
            print(url, 'failed:', e)
        time.sleep(0.6)

Run the numbers on a 10,000-row list where 2,000 rows survive segmentation. Emails-for-everyone costs 10,000 × 6 credits. The two-pass version costs 10,000 × 1 plus 2,000 × 5: a third of the price, for the same usable output. And it’s faster, because the second pass is a fifth of the volume.

So the flag in the pipeline isn’t decoration. want_email=False is the default posture; the email pass is a privilege your best segments earn.

The CSV reading is just Python’s standard csv module, with no dependencies beyond the SDK itself.

How long does 10,000 URLs take?

About 100 minutes for profile-only, and about 200 minutes with emails. That’s the rate limit doing the math for you:

💡 Scale math: 100 requests/min → 10,000 profile-only rows ≈ 100 minutes. Add emails and it doubles. Plan the run, don't babysit it.

The cap is 100 requests per minute per key on a fixed 60-second window; the usage limits page has the details. Exceed it and you get a 429 response, so anything serious should replace the flat sleep with a queue and retries with backoff and jitter. The flat sleep is fine for a few thousand rows; it’s wasteful at fifty thousand, because it never uses the full window.

What does “a queue” mean in practice? Instead of sleeping a fixed 0.7 seconds after every row, you track your own request timestamps and only pause when the trailing 60-second window is about to hit 100. That keeps you flush against the cap instead of idling below it: the difference between 100 minutes and 140 on a big run. It’s twenty lines of code, and worth writing exactly once.

And budget for the tail. On a clean, deduped list you’ll still see a few percent of rows fail: private profiles, dead slugs, the occasional timeout. That’s normal. What you want is for every one of those failures to be a labeled line in your checkpoint file, not a silent gap you discover during the campaign. The pipeline’s status field exists for exactly this audit.

Either way, this is a background job, not an afternoon of copy-paste. Start it, go do real work, come back to a finished file.

Writing the results back to your CRM

Enriched JSONL on disk is progress, but the point was never a file. The point is a CRM you can finally segment.

The mapping is mostly one-to-one, and the field table from earlier is your guide: job_title goes to the title property, company_website to the company domain field, country and city to your geo fields, company_size and company_industry to whatever drives your segmentation. Then work_email (from the email call) into the email property, ideally flagged as unverified until it passes your verification step.

Two habits make the writeback trustworthy:

  • Store the metadata. Save confidence_level and an enriched_at timestamp on the record. Six months from now, “how fresh is this field” will be an answerable question instead of a shrug.
  • Decide the conflict rule up front. When the enrichment says Acme and your CRM says Beta Corp, the enrichment is usually right: the person changed jobs and your CRM is the stale one. That’s the whole reason you’re enriching. Overwrite, but log what you overwrote.

In code, the writeback is a flattening exercise. Pull the nested person object out of each checkpoint line and map it onto your CRM’s property names:

def to_crm_row(record):
    p = (record.get('profile') or {}).get('data', {}).get('person') or {}
    email = (record.get('email') or {}).get('data', {}).get('work_email')
    return {
        'full_name':      p.get('full_name'),
        'job_title':      p.get('job_title'),
        'email':          email,
        'linkedin_url':   p.get('linkedin_url'),
        'company':        p.get('company_name'),
        'company_domain': p.get('company_website'),
        'company_size':   p.get('company_size'),
        'industry':       p.get('company_industry'),
        'country':        p.get('country'),
        'city':           p.get('city'),
        'enrich_confidence': (record.get('profile') or {})
                             .get('data', {}).get('confidence_level'),
    }

Feed that into your CRM’s import or API and you’re done. And once the emails are flowing into sequences, the outreach side has its own playbook; I covered it in using a LinkedIn email finder API for lead generation.

Keeping the column alive

One run fixes today. It doesn’t fix next year.

Remember the decay number: around a fifth of your records drift every year. So decide a refresh policy while the pipeline is still fresh in your head, because you will not want to rebuild this from scratch in eleven months. What works for us:

  • Event-triggered: re-enrich a record when an email hard-bounces or an active thread goes silent; those are job-change smoke signals.
  • Cadence-based: quarterly re-runs for the segments sales actively works; yearly for the archive.
  • On-entry: every NEW LinkedIn URL that lands in the CRM goes through the cleaner and the profile call the same week. Cheapest habit on this list; it keeps the mess from ever rebuilding.

The checkpoint file makes refreshes cheap, too: diff the current CRM column against what you enriched last time, and only re-run what changed. At 1 credit per profile, a quarterly touch-up on an active segment costs less than the coffee budget of the meeting where you’d argue about it.

Is this compliant?

Short answer: database-matching enrichment is standard B2B practice, but the outreach you do with it still needs a lawful basis in your market.

The enrichment itself doesn’t log into LinkedIn or touch anyone’s account; it matches your URL against an existing business database. That’s a meaningfully different posture from scraping. But the moment you email the people in that file, you’re doing direct marketing, and the rules for that depend on where THEY are. In Europe that usually means working through a legitimate-interests assessment; the ICO’s guidance is the clearest starting point I know. Other markets have their own regimes.

The practical rules I hold my own team to: business contacts only, never consumer lists. Honor opt-outs everywhere, immediately. And keep records of where each contact came from. None of this is legal advice; it’s the floor, and your counsel sets the ceiling.

One more habit that regulators and your own conscience both like: enrich what you’ll use, not everything you can. The two-pass pattern above is quietly a data-minimization pattern too: you only pull verified emails for people you have a genuine reason to contact. Good economics and good practice, same move.

What breaks

The person changed jobs. The profile data is current, but the company on your old record isn’t. Treat conflicts as the enrichment being right and your CRM being stale; that’s the entire point of running this.

Custom vanity URLs. Some people set a slug that doesn’t match their name at all. Nothing to do here: it either resolves or it doesn’t. Don’t “fix” vanity slugs by guessing; you’ll enrich the wrong human.

Private or deleted profiles. You’ll get nothing back. Log it and move on. Do NOT retry in a loop. A miss today is a miss tomorrow, and retry loops just burn your rate limit.

Company pages in the person column. The /in/ check in the cleaner catches these, but they’ll keep arriving with every new import. Make the cleaner a standing step, not a one-time fix.

Encoded slugs. Profiles in non-Latin scripts sometimes arrive percent-encoded from exports. If a batch from one region fails oddly, decode before you clean.

The slug changed since you collected it. People rename their vanity URL, and your two-year-old export still holds the old one. Sometimes it resolves anyway, sometimes it doesn’t, which is another argument for enriching new URLs when they arrive instead of hoarding them.

Duplicate URLs. Charged separately, every time. The checkpoint’s done set protects you within a run and across reruns, but dedupe the source column too.

The run that taught me all this

In 2022 I inherited an event follow-up list: about 3,000 LinkedIn URLs from badge scans and form fills, collected over two years by four different teams. The plan was simple: enrich everything, hand sales a segmented list by Friday.

The first cleaning pass dropped roughly a THIRD of the column. Tracking parameters. Company pages. /pub/ links from an export nobody remembered. A few hundred duplicates of the same fifty people, because every event re-collected the same regulars.

The team didn’t believe the number until they read the skip log themselves. But here’s the happy ending: the two thousand URLs that survived enriched beautifully, the match rate everyone had been complaining about jumped (because the inputs stopped being garbage), and cleaning became a standing pre-step on every import since.

The other thing that run taught me: spot-check before you trust. I now pull twenty random rows from every finished checkpoint file and eyeball them against the live profiles. Twenty rows, ten minutes, and you know whether your 93% is actually behaving like 93% on YOUR data, because a list skewed toward one region or one industry can drift from the average, and you want to know that before sales does.

🧠 Before you run: clean, dedupe, count survivors. Every list loses more URLs than its owner expects; find out for free, not halfway through a paid run.

Frequently asked questions

How do I find an email from a LinkedIn URL?

Pass the bare profile URL to the email-finder endpoint and it returns a verified work email at 94% confidence. One argument in, one address out: client.fwe('linkedin.com/in/…').

Is it possible to get someone’s email from LinkedIn itself?

Only if they’ve chosen to show it in their contact info, which most people haven’t. At any real scale you match the URL against a B2B database instead; that’s what an enrichment API does.

Can I enrich a LinkedIn company page instead of a person?

Not with these two endpoints. They expect a personal profile URL containing /in/. For company pages you want the company-side endpoints instead.

Do I need both calls?

No, and usually you shouldn’t make both. Profile enrichment for segmentation and scoring, email finder for outreach. Run both only on records that genuinely need the full picture plus a verified address.

How accurate are the emails?

94% confidence on the email finder: good enough for outbound at volume, but still worth a verification pass before a large campaign. Roughly one in sixteen will be wrong or missing; plan for it.

What’s the fastest way to run 10,000 URLs?

Clean and deduplicate first, checkpoint as you go, and respect the 100 requests per minute cap with a queue rather than a flat sleep. Profile-only lands around 100 minutes; adding emails doubles it.

Is LinkedIn enrichment legal?

Database-matching enrichment of business contacts is standard B2B practice and doesn’t touch LinkedIn accounts. Your outreach still needs a lawful basis in each market you contact; check guidance like the ICO’s legitimate-interests pages, and treat none of this as legal advice.

One last thing

Run the cleaner over your column before you make a single API call. Count how many URLs survive.

Every time I’ve done this, the number was lower than the team expected, sometimes by a third. And it’s much better to find that out for free than halfway through a paid run. The cleaner costs nothing, takes seconds, and tells you the truth about your data before you spend a single credit on it.

Then start the pipeline on a few hundred rows, watch the checkpoint file grow, and enjoy the specific satisfaction of a dead column coming back to life. When you’re ready to chain this into bigger builds, the rest of the API workflow guides are right here.

How would you rate this article?
Bad
Okay
Good
Amazing
Comments (0)
Comments (0)
98% accuracy, GDPR & CCPA ready

Prefer to Explore on Your Own?

Skip the call and start free: 15 credits, no credit card required. Upgrade or talk to us whenever you’re ready.

Free plan available · 50 credits/month · no credit card required