DEV Community

Cover image for Proxycurl Alternative in 2026: How to Get LinkedIn Profile and Company Data with an API (Python)
EnrichGrid
EnrichGrid

Posted on Edited on

Proxycurl Alternative in 2026: How to Get LinkedIn Profile and Company Data with an API (Python)

If your CRM sync, recruiting tool or lead-scoring pipeline called Proxycurl, it broke in July 2025 when Proxycurl shut down after LinkedIn's lawsuit. Since then a lot of teams have been looking for a replacement that returns the same kind of data: a LinkedIn URL goes in, a structured professional profile comes out.

This guide covers:

  1. Your options in 2026, and their trade-offs
  2. What data you can get from a LinkedIn URL
  3. Working Python code for profiles, companies and people search
  4. A Proxycurl → new API field mapping so your migration is a find-and-replace, not a rewrite
  5. Bulk enrichment of thousands of URLs, with rate limits and retries
  6. FAQ: legality, freshness, emails, cost

The examples use EnrichGrid, the API I work on. The patterns (polling, retries, concurrency, field mapping) apply to any provider.


Your options in 2026

Option Profile data? Effort Main risk
Official LinkedIn API Only the signed-in member's own profile, or partner programs with approval Apply, wait, hope Partner access is rarely granted for enrichment
Build your own scraper Yes High: proxies, logins, captchas, constant breakage Accounts get restricted, and maintenance never ends
Browser extensions that scrape inside your session Yes Low They act through your LinkedIn account
Data APIs (EnrichGrid, People Data Labs, Coresignal, Bright Data datasets…) Yes Low: one HTTP call Varies by vendor: freshness, pricing model, coverage

For most products, a data API is the practical choice: no LinkedIn account involved, no scraping infrastructure, predictable JSON. When you compare vendors, check four things:

  • Freshness. Is the profile collected live or from a months-old dump?
  • Pricing model. Are you charged per request or per found profile? On real-world lists, 10–30% of URLs are outdated, so this decides your bill.
  • Field coverage. Do you get full work history, or only the current job?
  • Minimums. Annual contracts and seat fees vs pay-as-you-go.

What data you can get from a LinkedIn URL

A person lookup returns, for a typical public profile:

  • name, headline, summary, location and country
  • current title, seniority (C-level, VP, director…) and job function
  • current company with its website, headcount and industry
  • every past job with start and end dates
  • education with degrees and fields of study
  • skills with endorsement counts
  • certifications, recommendations, courses, honors, publications
  • connections and followers

A company lookup returns firmographics: industry, headcount and range, website, HQ, founding year, type, specialties, description and followers.

What you won't get from a compliant provider: private emails or phone numbers scraped from profiles. Treat any vendor that promises personal contact details "from LinkedIn" with caution.


Step by step in Python

1. Get an API key

Create a free account at enrichgrid.com (50 free credits, no card) and copy the key from Dashboard → API keys. Keep it in an environment variable:

export ENRICHGRID_KEY="lk_..."
pip install requests
Enter fullscreen mode Exit fullscreen mode

2. Enrich a person

import os
import time
import requests

API = "https://enrichgrid.com/v1"
HEADERS = {"x-api-key": os.environ["ENRICHGRID_KEY"]}


def enrich_person(linkedin_url: str) -> dict | None:
    """Return the profile dict, or None when the profile doesn't exist (not charged)."""
    r = requests.post(f"{API}/enrich/person", headers=HEADERS,
                      json={"identifier": linkedin_url}, timeout=60)
    r.raise_for_status()
    data = r.json()

    # Rare profiles are collected live and answer 202 with a URL to poll.
    while data["status"] == "pending":
        time.sleep(2.5)
        data = requests.get(f"https://enrichgrid.com{data['poll']}", headers=HEADERS, timeout=30).json()

    return data["profile"] if data.get("found") else None


profile = enrich_person("https://www.linkedin.com/in/williamhgates")
print(profile["first_name"], profile["last_name"], "-", profile["headline"])
print("Now:", profile["current_title"], "at", profile["current_company_name"])
Enter fullscreen mode Exit fullscreen mode

The identifier accepts a full URL, the slug (williamhgates) or the profile ID, so you don't have to normalize URLs first.

The response is the same shape every time, which keeps parsing simple:

{
  "id": "7f3c2a9e…",
  "status": "done",
  "found": true,
  "credits_charged": 1,
  "kind": "person",
  "profile": {
    "first_name": "…",
    "headline": "…",
    "current_title": "…",
    "current_company_name": "…",
    "seniority": "cxo",
    "location": "…",
    "sections": {
      "experiences": [{"title": "…", "company_name": "…", "started_on": "2015-05-01", "ended_on": null, "is_current": true}],
      "educations": [{"school_name": "…", "degree_name": "…", "field_of_study": "…"}],
      "skills": [{"name": "…", "endorsement_count": 66}]
    }
  },
  "credits_left": 4999
}
Enter fullscreen mode Exit fullscreen mode

3. Walk the work history

Each job in sections.experiences carries the employer's firmographics in entreprise, so you get company size and website without a second call:

for job in profile["sections"]["experiences"]:
    company = job.get("entreprise") or {}
    end = job["ended_on"] or "present"
    print(f'{job["title"]} @ {job["company_name"]} ({job["started_on"]} → {end})'
          f' · {company.get("staff_count_range", "?")} employees · {company.get("website_url", "")}')
Enter fullscreen mode Exit fullscreen mode

4. Enrich a company

def enrich_company(identifier: str) -> dict | None:
    r = requests.post(f"{API}/enrich/company", headers=HEADERS, json={"identifier": identifier}, timeout=60)
    r.raise_for_status()
    data = r.json()
    return data["company"] if data.get("found") else None


c = enrich_company("https://www.linkedin.com/company/microsoft")
print(c["name"], c["industry"], c["staff_count_range"], c["website_url"], c["hq_city"])
Enter fullscreen mode Exit fullscreen mode

5. Search people by criteria

Find people by title, company, location, company size, tenure or school. Each page returns up to 10 profile slugs, which you then enrich:

def search_people(filters: dict, pages: int = 3) -> list[str]:
    slugs, cursor = [], None
    for _ in range(pages):
        r = requests.post(f"{API}/search", headers=HEADERS,
                          json={"filters": filters, "cursor": cursor}, timeout=60)
        r.raise_for_status()
        data = r.json()
        slugs += data["slugs"]
        cursor = data["next_cursor"]
        if not cursor:
            break
    return slugs


# Heads of sales at 11–200 person companies in Germany
slugs = search_people({
    "country": ["DE"],
    "title": {"any": ["head of sales", "vp sales"], "zones": ["current"]},
    "company_size": {"min": 11, "max": 200},
})
leads = [enrich_person(s) for s in slugs]
Enter fullscreen mode Exit fullscreen mode

Available filters: country, city, title, company, past_company, company_size, company_industry_id, industry_id, school, career_years, tenure_years. They combine with AND.


Migrating from Proxycurl: field mapping

Endpoints

Proxycurl EnrichGrid
GET /proxycurl/api/v2/linkedin?url=… POST /v1/enrich/person with {"identifier": url}
GET /proxycurl/api/linkedin/company?url=… POST /v1/enrich/company
GET /proxycurl/api/linkedin/school?url=… POST /v1/enrich/school
Person Search API POST /v1/search
Authorization: Bearer <key> x-api-key: <key>

Person fields

Proxycurl field EnrichGrid field
public_identifier slug
first_name, last_name first_name, last_name
headline headline
summary summary
occupation current_title + current_company_name
city, country location, location_country
experiences[].title sections.experiences[].title
experiences[].company sections.experiences[].company_name
experiences[].starts_at / ends_at (day/month/year objects) sections.experiences[].started_on / ended_on (ISO dates)
education[].school sections.educations[].school_name
education[].degree_name, field_of_study same names under sections.educations[]
skills (list of strings) sections.skills[].name (+ endorsement_count)
connections, follower_count num_connections, follower_count

A small adapter keeps the rest of your code untouched:

def to_proxycurl_shape(p: dict) -> dict:
    """Map an EnrichGrid profile to the Proxycurl fields most code reads."""
    s = p.get("sections", {})
    def ymd(d):
        if not d:
            return None
        y, m, dd = (int(x) for x in d.split("-"))
        return {"year": y, "month": m, "day": dd}
    return {
        "public_identifier": p.get("slug"),
        "first_name": p.get("first_name"),
        "last_name": p.get("last_name"),
        "full_name": " ".join(filter(None, [p.get("first_name"), p.get("last_name")])),
        "headline": p.get("headline"),
        "summary": p.get("summary"),
        "occupation": " at ".join(filter(None, [p.get("current_title"), p.get("current_company_name")])),
        "country": p.get("location_country"),
        "connections": p.get("num_connections"),
        "follower_count": p.get("follower_count"),
        "experiences": [{"title": e.get("title"), "company": e.get("company_name"),
                         "starts_at": ymd(e.get("started_on")), "ends_at": ymd(e.get("ended_on"))}
                        for e in s.get("experiences", [])],
        "education": [{"school": e.get("school_name"), "degree_name": e.get("degree_name"),
                       "field_of_study": e.get("field_of_study")} for e in s.get("educations", [])],
        "skills": [k["name"] for k in s.get("skills", [])],
    }
Enter fullscreen mode Exit fullscreen mode

Enriching thousands of URLs

For lists, run requests in parallel up to your plan's rate limit and respect Retry-After on 429:

from concurrent.futures import ThreadPoolExecutor
import csv

session = requests.Session()
session.headers.update(HEADERS)


def enrich_safe(url: str, attempts: int = 5) -> dict:
    for _ in range(attempts):
        r = session.post(f"{API}/enrich/person", json={"identifier": url}, timeout=90)
        if r.status_code == 429 or r.status_code == 503:   # rate limit or busy: nothing charged
            time.sleep(float(r.headers.get("Retry-After", 2)))
            continue
        if r.status_code == 402:
            raise RuntimeError("Out of credits")
        r.raise_for_status()
        data = r.json()
        while data["status"] == "pending":
            time.sleep(2.5)
            data = session.get(f"https://enrichgrid.com{data['poll']}", timeout=30).json()
        return {"url": url, "found": data.get("found"), **(data.get("profile") or {})}
    return {"url": url, "found": None}


urls = [line.strip() for line in open("linkedin_urls.txt") if line.strip()]
with ThreadPoolExecutor(max_workers=5) as pool:   # match your plan's requests/second
    rows = list(pool.map(enrich_safe, urls))

with open("enriched.csv", "w", newline="", encoding="utf-8") as f:
    fields = ["url", "found", "first_name", "last_name", "headline", "current_title",
              "current_company_name", "seniority", "location", "location_country"]
    w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
    w.writeheader()
    w.writerows(rows)
Enter fullscreen mode Exit fullscreen mode

A few practical notes:

  • Not-found profiles are free, so you don't need to pre-clean the list.
  • Repeat lookups within 30 days come from cache, instantly, so re-running a job after a crash is cheap and fast.
  • If you'd rather not write code, upload an Excel or CSV file in the dashboard (bulk enrichment) and download it with every field filled in.

Using it from an AI agent

The same data is available as an MCP server for Claude, Cursor and other agents. In Claude Code:

claude mcp add --transport http enrichgrid https://enrichgrid.com/mcp --header "x-api-key: $ENRICHGRID_KEY"
Enter fullscreen mode Exit fullscreen mode

Then ask the agent things like "enrich these 20 LinkedIn URLs and rank them by seniority".


FAQ

Is there an official LinkedIn API for profile data?
Not for general enrichment. LinkedIn's APIs give access to the signed-in member's own data, and broader access goes through partner programs that are rarely granted for this use case.

Is it legal to use LinkedIn profile data?
Public professional data is widely used for B2B sales, recruiting and research, but you are responsible for how you use it. If you process data about people in the EU, you need a lawful basis under GDPR (often legitimate interest for B2B), and you must honor opt-out and deletion requests. Don't use it for spam.

How fresh is the data?
EnrichGrid collects profiles live and caches them for up to 30 days. You can force a fresh collection with "live": true at the same price.

Do I get emails or phone numbers?
No. The API returns professional profile data only.

How much does it cost?
One credit per profile or company found; lookups that find nothing are free. 50 free credits on sign-up, then plans from $29/month for 10,000 credits, down to about $0.001 per profile at volume. See pricing.

What are the rate limits?
From 2 requests/second on the free tier to 50 on Enterprise. Responses include X-RateLimit-* headers, and 429 responses include Retry-After.


Wrapping up

Replacing Proxycurl is mostly three changes: a new endpoint and auth header, a field-mapping adapter, and polling for the rare profile that's collected live. The rest of your pipeline can stay as it is.

If you migrated from Proxycurl to something else, I'd like to hear what worked for you in the comments.

Top comments (0)