LeadGen/API
OpenAPI spec
Get an API key

Guide

  • Introduction
  • Get an API key
  • Authorization
  • Scopes
  • Find people at a company
  • Credits
  • Errors
  • Rate limits

Account

  • GETWho am I

Search

  • POSTFind people at a company
  • POSTFind companies
  • GETRead the people back
  • POSTCompany pipeline search
  • GETCompanies you already have

Reveal

  • POSTReveal an email
  • POSTReveal a personal email
  • POSTReveal a phone number
  • POSTReveal a batch
  • POSTReveal a whole list

Lists

  • GETRead a list
  • GETExport a list

LinkedIn

  • GETWhat the backend supports
  • POSTSearch LinkedIn
  • POSTScrape the full profiles
  • GETRead a scraped profile

Credits

  • GETCredit ledger

LeadGen API

Find the people at a company, reveal their work email and mobile number, export the list, and run cold sequences — from your own code.

https://leadgencopilot.ai/api/v1
HTTPS · JSON in, JSON out · one bearer token

Overview

Introduction

One REST API over the same data the LeadGen app runs on. There is no SDK to install and nothing to sign — every call is an HTTP request with your API key in the Authorization header. Searching is free; you are charged only when a contact detail is actually found.

Most integrations use three endpoints in a row: search a company, read the people back, then reveal the ones worth contacting. That flow is written out under Find people at a company.

Account access

Get an API key

Keys are created in the app at Settings → Developer → Create API key. A key starts with lgpat_, is shown exactly once, and is stored only as a hash — there is no way to display it again, so paste it into your secret store before you close the dialog. Lost one, revoke it and make another.

New keys carry Read, Search, Enrich and Export. Campaigns is off by default and has to be ticked deliberately.

Using the API

Authorization

Send the key as a bearer token on every request. That is the only credential — there is no separate secret, no signature, and no session to establish.

Header
Authorization: Bearer lgpat_your_key

Keep it server-side. A key carries your account's permissions and spends your credits, so it does not belong in browser JavaScript, a mobile app, or a committed .env. Revocation takes effect on the next request.

Is the API host meant to be public?

Yes — https://leadgencopilot.ai/api/v1 is the product, the same way Stripe publishes api.stripe.com, Twilio publishes api.twilio.com, and GitHub publishes api.github.com. A base URL cannot be a secret: every customer's integration has to know it, and it ships in our browser extension.

Nothing is protected by the address being obscure. Every route authenticates the bearer token, narrows it to the scopes on that key, throttles it per key, and resolves the acting account server-side — so a request carrying someone else's account id in the body still only ever touches the data belonging to the key that sent it. Your key is the secret; the host is not.

Using the API

Scopes

A key carries only the permissions you gave it, and they are checked on every request. Pick the narrowest set that does the job — a key that only exports should not be able to spend credits.

ScopeGrantsOn by default
leadgen:readRead lists, contacts, companies, search results and credit activityYes
leadgen:write:searchRun people and company searches; create and edit lists and foldersYes
leadgen:write:enrichReveal emails and phones, scrape profiles — the scope that spends creditsYes
leadgen:exportDownload CSV and XLSX exportsYes
leadgen:write:campaignsSend campaigns — anything that can put mail in front of a real personNo

A call outside a key's scopes returns 403 naming the one it needed, so you never have to guess which permission to add.

What a key can never do

Account management is not scopable, and every key is refused with 403 session_required: issuing or revoking API keys, deleting the account, revoking sessions, adding or removing teammates, and starting a card payment. Those happen in the web app, signed in. A key cannot widen itself.

Using the API

Find people at a company

The flow most integrations start with. It is two calls, not one: the search runs the query and files the results, then you read the people back. The search response deliberately carries a count rather than the rows, so paging through a large company does not mean re-sending everything you already have.

1

Search

POST /api/search/people with a company_name. Free. Returns a job_id, a list_id and how many people that page added.

2

Read

GET /api/search/{job_id}/results. Free. Returns the contacts — names, titles, LinkedIn URLs, company domain.

3

Reveal

POST /api/contacts/{id}/reveal-email or /get-phone with an id from step 2. This is the step that costs credits.

The whole flow
# 1. Search — free, returns a job_id and how many people it found
curl -X POST https://leadgencopilot.ai/api/v1/search/people \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{"company_name": "Notion", "page": 1}'
# -> { "job_id": "ec8254b7-...", "list_id": "988b71b2-...", "count": 25, "has_more": true }

# 2. Read the people back — free
curl https://leadgencopilot.ai/api/v1/search/ec8254b7-.../results \
  -H "Authorization: Bearer lgpat_your_key"
# -> { "count": 25, "contacts": [ { "id": "b2868147-...", "name": "Aditi Pareek", ... } ] }

# 3. Reveal one of them — 2 email credits, 5 phone credits
curl -X POST https://leadgencopilot.ai/api/v1/contacts/b2868147-.../reveal-email \
  -H "Authorization: Bearer lgpat_your_key"
# -> { "email": "a.pareek@example.com", "email_status": "valid", "credits_email": 926 }

Paging through a big company

One call fetches one provider page — 25 people. For page 2, send the same filters again plus the job_id and list_id you were given, and the new rows accumulate into the same search and the same list. Stop when has_more is false. Sending the filters without the ids starts a second, unrelated search.

Full parameters for POST /api/search/people

Using the API

Credits

Searching is free. Contact details are not — and a lookup that finds nothing is not charged.

ActionCostCharged when
People or company searchFreeNever
Reading results, lists, companiesFreeNever
LinkedIn count and capabilitiesFreeNever
Full LinkedIn profile scrape0.2 profileReserved up front, refunded per profile that comes back empty
Email reveal2 emailOnly when an address is found
Phone reveal5 phoneOnly when a number is found

Running short returns 402 naming the credit type. Every reveal response echoes the balance that remains, so a script can throttle itself without a second call, and GET /api/payment/credit-activity is the itemised ledger — which key spent what, on which contact. A contact you have already paid for is free forever after and comes back with cached: true.

Using the API

Errors

Every failure is JSON with an error field. The status tells you whether to fix the request, the key, the balance, or to retry.

StatusMeaningWhat to do
400The body failed validationerror names the field
401Key missing, malformed, revoked or expiredcheck the Authorization header, then the key in Settings
402Out of credits for that reveal typetop up; the message names email or phone
403Key is missing a scope, or the route is session-onlyadd the scope in required_scope, or use the web app
404No such record — or no such routethe body names the method and path when the route is the problem
409strict_filters refused a degraded searchdrop the filter named in unsupported_filters
429Over an hourly limitback off; the limit is in the body
5xxOur side, or an upstream providerretry with backoff

A 404 on a route you believe exists is usually the verb — the body names the method and path that did not match. A 401 means the credential, never the URL.

Using the API

Rate limits

10,000 requests per hour per key by default, settable per key when you create it. Each key is throttled on its own, so a runaway integration cannot starve the others.

429 response
{
  "error": "This API key has exceeded its limit of 10000 requests per hour",
  "limit": 10000
}

A few endpoints carry a tighter limit on top: people search is 300 per hour per account, profile scrapes 5,000, single reveals 100, and the company pipeline 10 per hour per IP. The counters are hourly windows, so retry with exponential backoff rather than tight polling.

API reference

Every endpoint below was called against production while writing this page — the responses are real, with one person's email and phone number replaced. The complete machine-readable surface is at https://leadgencopilot.ai/api/v1/openapi, browsable at /docs. Both are public and need no key.

Account

Who am I

GET/api/v1/users/meno scope

Confirms a key is live and returns the account it belongs to, along with the credit balances every reveal draws from. The one endpoint that needs no scope — a key narrowed to a single permission still has to be able to identify itself. Use it as your integration's health check.

Request
curl https://leadgencopilot.ai/api/v1/users/me \
  -H "Authorization: Bearer lgpat_your_key"
{
  "user_id": "wc7R4W3HRaAFKbqgu5vm",
  "email": "you@example.com",
  "plan": "pro",
  "credits_email": 928,
  "credits_phone": 452,
  "team_id": "ee138fa8-4232-4bea-b4f0-38522033142b"
}

Search

Find people at a company

POST/api/v1/search/peopleleadgen:write:search

Send a company, get the people who work there. Costs nothing. Runs one provider page — 25 people — synchronously, files them into a new list, and answers with a count rather than the rows; read them back with the results endpoint below.

Body

namestring
A person's name, matched loosely — "sachin narula" finds "Sachin K. Narula". Combine it with company_name to disambiguate a common name.
company_namestring
The company to search within. Omit it to search every company at once — the other filters still apply.
rolesstring[]
Job titles to match, e.g. ["VP Sales", "Head of Growth"]. Omit for everyone at the company.
locationstring
Country the person is in, e.g. "United States".
industriesstring[]
Industries the company operates in.
company_hq_locationstring[]
Countries the company is headquartered in — distinct from where the person sits.
pagenumber
Which provider page to fetch. Defaults to 1. See the pagination note below.
job_idstring
Returned by page 1. Send it back on page 2 and beyond to keep accumulating into the same search.
list_idstring
Returned by page 1. Send it with job_id so later pages land in the same list.
reveal_emailsboolean
Reveal every result's email as it arrives. Spends credits, so it additionally requires leadgen:write:enrich.

To go deeper, send the same filters again with page: 2 plus the job_id and list_id you got back — results accumulate into the same list instead of starting a new search. Stop when has_more is false.

Request
curl -X POST https://leadgencopilot.ai/api/v1/search/people \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Notion",
    "roles": ["Customer Success Manager"],
    "page": 1
  }'

count is this page; total is every match across all pages. total is null when the provider doesn't report one — treat count plus has_more as a lower bound in that case.

{
  "job_id": "ec8254b7-9ec7-4346-8b16-325401500a89",
  "list_id": "988b71b2-d627-4bb2-a358-ac24a78788c4",
  "company_name": "Notion",
  "page": 1,
  "has_more": true,
  "count": 25,
  "total": 1432
}

Search

Find companies

POST/api/v1/search/companiesleadgen:write:search

Search the company directory rather than the people in it. Costs nothing, spends no credits, and writes nothing — no job and no list, so there is no id to read back. Take a domain from the results into the people search to get its contacts.

Body

company_namestring
Name or domain. A bare word is also tried with common TLDs, so "stripe" matches stripe.com — but the full domain matches most reliably.
industriesstring[]
Industries the company operates in.
hq_locationstring[]
Countries the company is headquartered in.
pagenumber
Which page to fetch. Defaults to 1.

Any field can come back null — the provider's coverage varies by company, and size in particular is often missing for private companies.

Request
curl -X POST https://leadgencopilot.ai/api/v1/search/companies \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "stripe.com",
    "page": 1
  }'
{
  "page": 1,
  "count": 1,
  "total": 1,
  "has_more": false,
  "companies": [
    {
      "id": "1043takN2p",
      "name": "Stripe",
      "domain": "stripe.com",
      "industry": "Financial Services",
      "location": "South San Francisco, California",
      "size": 8000,
      "linkedin_url": "https://www.linkedin.com/company/stripe",
      "logo_url": null
    }
  ]
}

Search

Read the people back

GET/api/v1/search/{job_id}/resultsleadgen:read

The rows the search collected. Free, and safe to call repeatedly — call it again after each page to see what that page added. Take each contact's id into the reveal endpoints.

email and phone_number are null here on purpose: searching is free, and contact details are only fetched when you ask and pay for them. Fields the provider did not supply — linkedin_url, domain, location — also come back null rather than being omitted.

Request
curl https://leadgencopilot.ai/api/v1/search/ec8254b7-9ec7-4346-8b16-325401500a89/results \
  -H "Authorization: Bearer lgpat_your_key"
{
  "job_id": "ec8254b7-9ec7-4346-8b16-325401500a89",
  "count": 25,
  "contacts": [
    {
      "id": "b2868147-b702-4fb1-97d0-25cfa9d261a8",
      "name": "Aditi Pareek",
      "first_name": "Aditi",
      "last_name": "Pareek",
      "title": "Enterprise Customer Success Manager",
      "role_category": "Manager",
      "seniority": "Manager",
      "department": "Customer Service",
      "linkedin_url": null,
      "profile_image_url": "https://media.licdn.com/dms/image/...",
      "location": null,
      "company": "notion",
      "domain": null,
      "industry": null,
      "revenue": null,
      "email": null,
      "email_status": null,
      "confidence": null,
      "phone_number": null,
      "phone_status": null
    }
  ]
}

Search

Company pipeline search

POST/api/v1/searchleadgen:write:search

The deeper, asynchronous alternative to people search: takes a company name or its LinkedIn URL, works the company over in the background, and files results into the same shape. Returns 202 immediately; poll GET /api/search/{id} until status is completed, then read the results endpoint above.

Body

company_namestring
Required unless you send linkedin_company_url.
linkedin_company_urlstring
A linkedin.com/company/... URL. The company name is derived from it when you omit company_name.
rolesstring[]
Job titles to target.
max_contactsnumber
How many contacts to collect. Defaults to 25.
filtersobject
hq_location, industry, headcount, revenue, year_founded, funding, company_types, summary_keywords.
force_refreshboolean
Skip the cache. Without it an identical recent search returns the earlier job with cached: true, instantly and for free.
reveal_emailsboolean
Reveal as results arrive. Spends credits; additionally requires leadgen:write:enrich.
Request
curl -X POST https://leadgencopilot.ai/api/v1/search \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "company_name": "Stripe",
    "roles": ["VP Sales"],
    "max_contacts": 50
  }'
{
  "job_id": "355989f1-402f-4be1-bec8-d580b3f731b5",
  "status": "queued",
  "company_name": "Stripe"
}

Search

Companies you already have

GET/api/v1/contacts/companiesleadgen:read

Every company with at least one contact in your account, deduped across every search you have run, with counts of how many of those contacts have an email. Reads your own data — no provider call, no credits. Pair it with /api/contacts/companies/{companyId}/contacts to pull the people back out.

Query

qstring
Filter by company name.
limitnumber
Defaults to 200, capped at 500.
Request
curl "https://leadgencopilot.ai/api/v1/contacts/companies?q=stripe&limit=5" \
  -H "Authorization: Bearer lgpat_your_key"
{
  "count": 2,
  "companies": [
    {
      "id": "32022c09-8d05-4190-962a-204b1a282070",
      "name": "stripe",
      "domain": "stripe.com",
      "logo_url": "https://leadgencopilot.ai/api/company-logo/stripe.com",
      "linkedin_url": "https://www.linkedin.com/company/stripe/",
      "total_contacts": 614,
      "contacts_with_email": 30,
      "latest_job_id": "29b2a1a1-85b9-4184-961e-f541d90f02bd",
      "latest_job_created_at": "2026-07-28 18:47:16.009283+00"
    }
  ]
}

Reveal

Reveal an email

POST/api/v1/contacts/{id}/reveal-emailleadgen:write:enrich

Runs a waterfall across providers until one returns a verified work address. Synchronous and typically takes five to fifteen seconds. Costs 2 email credits, and only when an address comes back.

Path

iduuidrequired
A contact id from any search result.

Body

force_refreshboolean
Re-run a lookup you have already paid for. Charges again.

email_status is "valid" when the address was verified deliverable, "unknown" when a provider returned it without verifying, and "not_found" on a miss. cached: true means you have already paid for this contact and it is free from now on. Only work addresses are returned — if no company address exists the waterfall stops rather than falling back to a personal mailbox.

Request
curl -X POST \
  https://leadgencopilot.ai/api/v1/contacts/b2868147-b702-4fb1-97d0-25cfa9d261a8/reveal-email \
  -H "Authorization: Bearer lgpat_your_key"
{
  "contact_id": "b2868147-b702-4fb1-97d0-25cfa9d261a8",
  "email": "a.pareek@example.com",
  "email_status": "valid",
  "email_source": "leadgen",
  "cached": false,
  "credits_email": 926
}

Reveal

Reveal a personal email

POST/api/v1/contacts/{id}/reveal-personal-emailleadgen:write:enrich

Finds the consumer mailbox — gmail, outlook, icloud — rather than the company address. A separate paid lookup from the work reveal, not a fallback for it. Costs 2 email credits from the same balance, and only when an address comes back.

Path

iduuidrequired
A contact id from any search result.

Body

force_refreshboolean
Re-run a lookup you have already paid for. Charges again.

This and the work reveal are two different products, billed and cached separately: paying for one does not make the other free, and neither falls back to the other. Asking here will never hand back a company address, exactly as /reveal-email will never hand back a personal one. personal_email_status reads "valid" when the provider verified the mailbox and "unknown" when it returned the address without verifying. cached: true means you have already paid for this contact's personal address and it is free from now on. Only one provider answers this lookup, so it is usually faster than a work reveal — a few seconds rather than up to fifteen.

Request
curl -X POST   https://leadgencopilot.ai/api/v1/contacts/b2868147-b702-4fb1-97d0-25cfa9d261a8/reveal-personal-email   -H "Authorization: Bearer lgpat_your_key"
{
  "contact_id": "b2868147-b702-4fb1-97d0-25cfa9d261a8",
  "personal_email": "a.pareek@gmail.com",
  "personal_email_status": "valid",
  "cached": false,
  "credits_email": 924
}

Reveal

Reveal a phone number

POST/api/v1/contacts/{id}/get-phoneleadgen:write:enrich

The same shape and the same rules as the email reveal, against the mobile-number providers. Costs 5 phone credits, and only when a number comes back.

Path

iduuidrequired
A contact id from any search result.

Body

force_refreshboolean
Re-run and charge again.

Numbers come back in E.164. Both reveal responses echo the balance that remains, so a script can throttle itself without a second call.

Request
curl -X POST \
  https://leadgencopilot.ai/api/v1/contacts/b2868147-b702-4fb1-97d0-25cfa9d261a8/get-phone \
  -H "Authorization: Bearer lgpat_your_key"
{
  "contact_id": "b2868147-b702-4fb1-97d0-25cfa9d261a8",
  "phone_number": "+14155550142",
  "phone_status": "found",
  "phone_source": "leadgen",
  "cached": false,
  "credits_phone": 447
}

Reveal

Reveal a batch

POST/api/v1/contacts/batch-reveal-emailsleadgen:write:enrich

Emails for an explicit set of contacts in one response. /api/contacts/batch-get-phones is identical for numbers. Both are synchronous — for a whole list, use the asynchronous reveal job below instead, because a large batch will outlive the request timeout.

Body

contact_idsuuid[]required
Contact ids to reveal. There is a per-request cap; oversized batches return 400 naming it.
force_refreshboolean
Re-run lookups already paid for.
Request
curl -X POST https://leadgencopilot.ai/api/v1/contacts/batch-reveal-emails \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{"contact_ids": ["b2868147-...", "1fb5f36f-..."]}'
{
  "revealed": 2,
  "not_found": 0,
  "credits_email": 922,
  "results": [
    {
      "contact_id": "b2868147-b702-4fb1-97d0-25cfa9d261a8",
      "email": "a.pareek@example.com",
      "email_status": "valid",
      "cached": false
    }
  ]
}

Reveal

Reveal a whole list

POST/api/v1/reveal-jobsleadgen:write:enrich

Creates a background job that works through every contact in a list, so you are not holding a connection open. Poll GET /api/reveal-jobs/{id} until it completes, then read GET /api/reveal-jobs/{id}/items for the per-contact outcome.

Body

list_iduuidrequired
The list to reveal. Every search returns one.

There is no GET /api/reveal-jobs collection endpoint — keep the id you were given. Contacts whose address you already own come back free and are never re-charged.

Request
curl -X POST https://leadgencopilot.ai/api/v1/reveal-jobs \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{"list_id": "988b71b2-d627-4bb2-a358-ac24a78788c4"}'
{
  "reveal_job_id": "7c1e0b44-2f9d-4a6e-9f3b-8d2a1c5e4f70",
  "status": "queued",
  "total": 25
}

Lists

Read a list

GET/api/v1/lists/{listId}leadgen:read

The list and the contacts in it. Note there is no GET /api/lists/{listId}/contacts — that path exists only for POST and DELETE, and a GET against it returns 404. GET /api/lists returns every list in the account.

Request
curl https://leadgencopilot.ai/api/v1/lists/988b71b2-d627-4bb2-a358-ac24a78788c4 \
  -H "Authorization: Bearer lgpat_your_key"
{
  "list": {
    "id": "988b71b2-d627-4bb2-a358-ac24a78788c4",
    "name": "Notion",
    "description": null,
    "color": "#6366f1",
    "source_job_id": "ec8254b7-9ec7-4346-8b16-325401500a89",
    "created_at": "2026-08-07T18:03:13.655Z"
  },
  "contacts": []
}

Lists

Export a list

GET/api/v1/lists/{listId}/exportleadgen:export

Downloads the list as a file rather than JSON. Columns: Name, Company, Job Title, Department, Website, Industry, Revenue, Location, Email, Email Status, Phone, LinkedIn.

Query

format"csv" | "xlsx"
Defaults to csv.
Request
curl "https://leadgencopilot.ai/api/v1/lists/988b71b2-.../export?format=csv" \
  -H "Authorization: Bearer lgpat_your_key" -o leads.csv

text/csv, as an attachment — not JSON.

Name,Company,Job Title,Department,Website,Industry,...
"Andrew Lam","stripe","Engineer","Engineering and Technical",...

LinkedIn

What the backend supports

GET/api/v1/linkedin/capabilitiesleadgen:read

Call this first. It tells you which filters are applied at the source, which are approximated, and what each phase costs — so you never ship a filter that silently does nothing. Search and full-profile capture run on two different backends, both fixed, so this answer is stable and safe to cache.

native means it is applied at the source, before a row is ever collected — those results already match. emulated means we make it mean something anyway: skills and schools fold into a whole-profile text match, and excluded titles are dropped from the rows returned before they ever become contacts, so they never cost a credit. unsupported means it will not narrow the search at all. verified_after_scrape is the separate promise: those filters are re-checked against the full record once you scrape, with a pass/fail/not_evaluated reason per person — so a filter listed as unsupported here can still be answered exactly, just later and only for the profiles you paid to fetch.

Request
curl https://leadgencopilot.ai/api/v1/linkedin/capabilities \
  -H "Authorization: Bearer lgpat_your_key"
{
  "provider": "leadgen",
  "profile_provider": "leadgen",
  "filters": {
    "native": ["locations", "company_name", "job_titles",
               "seniority_keywords", "industries", "company_hq_locations",
               "min_years_experience", "keywords"],
    "emulated": ["exclude_job_titles", "skills", "schools"],
    "unsupported": ["name", "max_years_experience", "company_sizes"],
    "verified_after_scrape": ["job_titles", "exclude_job_titles",
                              "seniority_keywords", "min_years_experience",
                              "max_years_experience", "skills", "schools",
                              "keywords", "locations", "company_name",
                              "industries", "name"]
  },
  "count": { "supported": true, "exact": true, "free": true },
  "preview": { "supported": true, "max": 50, "free": true,
               "typical_latency_ms": 1500 },
  "limits": { "max_results_per_search": 1000, "max_scrape_per_job": 1000 },
  "credits": { "profile": 0.2, "email": 2, "phone": 5 },
  "requires_at_least_one_of": []
}

LinkedIn

Search LinkedIn

POST/api/v1/linkedin/searchleadgen:write:search

Starts a filtered people search. Returns 202 with a job_id and a list_id; poll GET /api/linkedin/search/{jobId}. Searching itself spends nothing — scraping the profiles it finds is the separate, paid call below.

Body — filters applied at the source

job_titlesstring[]
Current titles to match.
seniority_keywordsstring[]
Free text matched against the title, e.g. "Head of". A "5+ years" string here is read as a years bound instead.
min_years_experiencenumber
Total years of experience, lower bound. Applied exactly.
locationsstring[]
Where the person is.
company_namestring
Restrict to one current employer.
industriesstring[]
Employer industry.
company_hq_locationsstring[]
Where the employer is based.
keywordsstring[]
Free text matched anywhere on the profile.

Body — filters approximated or deferred

exclude_job_titlesstring[]
Applied by us to the rows that come back — matching people are dropped before they become contacts, so they never cost a credit.
skillsstring[]
Matched against the profile text rather than a real skills field. Settled exactly after a scrape.
schoolsstring[]
Same as skills.
max_years_experiencenumber
NOT applied at search time. Settled after a scrape — read it back with ?match=matches_filters.
company_sizesstring[]
Headcount buckets, e.g. "51-200". Not applied and not settled later — a person's profile carries no employer headcount.

Body — the run

max_resultsnumber
Cap on how many matching people to collect. Defaults to 100, ceiling 1000.
list_namestring
Name the list this creates.
list_iduuid
File results into an existing list instead.
auto_scrapeboolean
Chain the paid scrape below as soon as the search finishes. Off by default — this is the flag that spends credits.
strict_filtersboolean
Return 409 rather than running a search that cannot be expressed exactly. This is the setting production integrations should use.

Always read unsupported_filters — anything listed there did not narrow the search, so the results are broader than the filters you sent. The same list comes back on the status endpoint. Note the search collects only people who already match the filters it could apply; nothing is collected and then discarded, so max_results is a cap on matches, not on rows inspected.

Request
curl -X POST https://leadgencopilot.ai/api/v1/linkedin/search \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "job_titles": ["Backend Developer"],
    "min_years_experience": 10,
    "locations": ["Berlin"],
    "max_results": 200,
    "strict_filters": true
  }'
{
  "job_id": "4b1a7c90-...",
  "list_id": "e91f2d33-...",
  "status": "queued",
  "provider": "leadgen",
  "applied_filters": ["locations", "job_titles", "min_years_experience"],
  "unsupported_filters": [],
  "max_results": 200,
  "poll": "/api/linkedin/search/4b1a7c90-..."
}

LinkedIn

Scrape the full profiles

POST/api/v1/linkedin/scrapeleadgen:write:enrich

Fetches the complete LinkedIn record for the people a search found. 0.2 profile credits each, reserved before the job starts and refunded per profile that cannot be fetched. Contacts already scraped are skipped unless you pass force_refresh.

Body — pick exactly one selector

job_iduuid
Scrape everyone a search found.
list_iduuid
Scrape everyone in one of your lists.
contact_idsuuid[]
Scrape specific people, up to 1000.
linkedin_urlsstring[]
Scrape profiles with no search behind them. Each URL becomes a contact, filed into a new list.

Body — options

max_itemsnumber
Cap on profiles to fetch this run. Defaults to 1000, which is also the ceiling.
force_refreshboolean
Re-scrape profiles already on file.
list_namestring
Names the list created when scraping bare linkedin_urls.

Exactly one of job_id, list_id, contact_ids or linkedin_urls — two selectors would make the credit estimate a guess, and the point of the 202 is that it states the cost up front. total is what will actually be fetched: people with no LinkedIn URL on file are reported as skipped_no_linkedin_url rather than silently dropped, so check that field if the count is lower than you expected. Poll GET /api/linkedin/scrape/{scrapeJobId} every few seconds and read the results back with GET /api/linkedin/search/{jobId}/results?include=profile, which caps the page at 25. profile is the raw provider payload whose shape can change; normalized is the stable projection to build against.

Request
curl -X POST https://leadgencopilot.ai/api/v1/linkedin/scrape \
  -H "Authorization: Bearer lgpat_your_key" \
  -H "Content-Type: application/json" \
  -d '{"job_id": "4b1a7c90-...", "max_items": 200}'
{
  "scrape_job_id": "aa20fd51-...",
  "status": "queued",
  "provider": "leadgen",
  "total": 187,
  "skipped_no_linkedin_url": 13,
  "skipped_already_scraped": 0,
  "truncated": false,
  "credits_reserved": 37.4,
  "credits_profile_remaining": 462.6,
  "poll": "/api/linkedin/scrape/aa20fd51-..."
}

LinkedIn

Read a scraped profile

GET/api/v1/linkedin/profiles/{contactId}leadgen:read

Returns a profile you have already scraped. Reads from cache only and never spends a credit — if the contact has not been scraped it answers 409 rather than quietly fetching and billing you. Every response carries both shapes: profile is the provider's record verbatim, normalized is the projection this API keeps stable.

Read is_current, not end_date, to find someone's present role — the provider writes the literal string "Present" there, never null, so an `end_date === null` check never matches. Several roles at one employer arrive from the provider grouped under a single entry whose own dates are absent; positions[] flattens them into one row per role, so a 22-year tenure recorded as two promotions appears as two dated rows rather than one undated one. total_experience_months merges overlapping spans instead of summing them — concurrent roles, advisory seats and a promotion logged twice would otherwise inflate a 22-year career into 40. skills is currently always empty: the person dataset does not return a skills array, and it is left empty rather than guessed at from the About text. education, certifications, languages and positions are always arrays, never null — an empty one means the profile had none. The provider also returns honors_and_awards, volunteer_experience, projects, courses, publications, recommendations, posts and activity; those are not in normalized yet and are available on profile.

Request
curl https://leadgencopilot.ai/api/v1/linkedin/profiles/b2868147-... \
  -H "Authorization: Bearer lgpat_your_key"
{
  "contact_id": "b2868147-...",
  "linkedin_url": "https://www.linkedin.com/in/sundarpichai",
  "scraped_at": "2026-08-14T09:12:04.552Z",
  "email": "s.pichai@example.com",
  "phone_number": null,

  "normalized": {
    "full_name": "Sundar Pichai",
    "headline": "CEO at Google",
    "about": "…",
    "location": "Mountain View",
    "city": "Mountain View, California, United States",
    "country_code": "US",
    "profile_image_url": "https://media.licdn.com/…",
    "linkedin_url": "https://www.linkedin.com/in/sundarpichai",
    "connections": 500,
    "followers": 1204533,

    "current_position": {
      "title": "CEO",
      "company_name": "Google",
      "company_linkedin_url": "https://www.linkedin.com/company/google",
      "location": "Mountain View",
      "start_date": "2015",
      "end_date": null,
      "is_current": true,
      "duration": null,
      "description": null
    },
    "current_company_industry": "Software Development",

    "positions": [
      {
        "title": "CEO",
        "company_name": "Google",
        "company_linkedin_url": "https://www.linkedin.com/company/google",
        "location": null,
        "start_date": "2015",
        "end_date": "Present",
        "is_current": true,
        "duration": null,
        "description": null
      },
      {
        "title": "Product Management + Leadership",
        "company_name": "Google",
        "company_linkedin_url": "https://www.linkedin.com/company/google",
        "location": null,
        "start_date": "Apr 2004",
        "end_date": "2015",
        "is_current": false,
        "duration": null,
        "description": null
      }
    ],

    "education": [
      { "school": "Stanford University", "degree": "MS, Material Sciences",
        "start_year": "1993", "end_year": "1995", "url": "https://…" }
    ],
    "certifications": [
      { "title": "…", "issuer": "…", "issued": "…", "credential_url": null }
    ],
    "languages": [ { "name": "English", "proficiency": "Native" } ],
    "skills": [],

    "total_experience_months": 269,
    "estimated_years_experience": 22
  },

  "profile": { "…": "the provider's record, verbatim — shape may change" }
}

Credits

Credit ledger

GET/api/v1/payment/credit-activityleadgen:read

Itemised: which key spent what, on which contact, and whether it was charged or refunded. The endpoint to reconcile against when a bill looks wrong.

A key cannot buy credits — topping up is session-only, in the web app.

Request
curl "https://leadgencopilot.ai/api/v1/payment/credit-activity?limit=50" \
  -H "Authorization: Bearer lgpat_your_key"
{
  "count": 2,
  "activity": [
    {
      "credit_type": "phone",
      "amount": 5,
      "status": "charged",
      "contact_id": "b2868147-...",
      "description": "Phone lookup for Aditi Pareek",
      "created_at": "2026-08-07T18:31:44.201Z"
    },
    {
      "credit_type": "email",
      "amount": 2,
      "status": "refunded",
      "description": "Email not found — credit returned",
      "created_at": "2026-08-07T18:29:02.884Z"
    }
  ]
}

Something here wrong or missing? Get a key and try it — then tell us what did not match.