Break a search past the 2,500-row ceiling
One search too big for LinkedIn, covered by many smaller ones.
https://api.easydata.win/v1/sales/search/deepLinkedIn serves 2,500 rows of any search however many match it. A search with 40,000 people in it is not one request away from being answered - it is answered by running a set of NARROWER searches that between them cover the same people. This works out which, and then runs them.
It plans first, and you approve the plan. The batch counts your search, works out which filter actually divides it, and splits it into as few pieces as the 2,500-row ceiling allows - recursing on whatever is still too big. The first result entry is the quote: how many shards, how many rows each, what fraction of the search they reach and what the rest will cost. Nothing else runs until you POST /batches/{id}/approve - or until a plan clears the auto_approve_coverage you named up front.
The shards union back to your search. A filter you constrained is subdivided within YOUR values; a filter you left open is partitioned across all of its, one shard often carrying several values at once. Nothing is ever narrowed by a value you did not ask for. That is the difference between this and bolting two filters onto a search and hoping - the tools that do the latter hand back a different population from the one you described.
Coverage is measured rather than promised. Where a filter's values sum to less than the search they came out of, the difference is people LinkedIn cannot classify on that filter - a profile with no dates on its current position, against a tenure filter. Every one of them is counted, on the branch that lost it, and reported under residuals.
Most of them are then recovered. LinkedIn accepts an exclusion on the function and seniority filters, so where you left either open, "none of these values" is a real search naming exactly the people that filter could not place - and it becomes another shard rather than a shortfall. Those two are preferred wherever they divide a search at all, because a split on them loses nobody. Expect the high nineties on a broad search, and less on one whose own filters have already settled the useful ones - a search for a single job title has decided its function and very nearly its seniority, and what is left to split on leaks.
The price is every other search's: 50 credits a request, and both halves are requests. A count is one and a page is one, so there is no premium for the breaking and nothing here you cannot derive.
Need it in the response?POST https://api.easydata.win/v1/sales/search/deep/sync runs the same operation inside your request and hands back the record, for 100per request - twice the price above, because it outranks every queued batch for a worker and holds one open while you wait. It takes target, one entity, never a targets array. See Synchronous requests.
Request #
| Field | Type | Notes |
|---|---|---|
targetsrequired | array | One or more pasted Sales Navigator search URLs, as { "searchUrl": "..." } or a bare string. Each is paginated independently. |
max_resultsrequired | integer | How many rows you want FROM EACH search URL, and the number you are billed for as they arrive. Every entry in targets is paginated independently, so two search URLs with max_results 500 is up to 1,000 rows and a quote of 500 credits - not 500 rows shared between them. Above the ceiling this is refused at submit time - that ceiling is LinkedIn's own, not ours, and it applies per search URL for the same reason. |
enrich | boolean | Fold the full record into every row, under profile for people and company for companies. Adds exactly what that enrich endpoint costs, +1 per ROW either way, charged only on the rows that actually resolved. A row whose record could not be fetched arrives as a plain stub and adds nothing to the request its page already cost, and the absent field is how you tell. |
find_emails | boolean | Fold a work email address into every row, under emails. Adds a flat +4 per ROW that reached a verdict, so a page of a hundred where sixty resolve is +240 rather than +400. Independent of enrich - a search card already carries the name and the current employer, which is everything a lookup needs - so you can ask for addresses without buying the full records. A page with this set arrives whole rather than row by row, the same way an enriched page does. |
external_id | string | Your own correlation handle. It comes back on the batch, on the completion webhook and in the usage rollup, so spend can be attributed to your pipeline rather than only to ours. |
callback_url | string | Where to deliver batch.completed. Optional - polling is a first-class path, not a fallback. Validated when you send it and again when we dial it. Mutually exclusive with webhook_tag. |
webhook_tag | string | Route the completion to the endpoints carrying this tag instead of to the untagged ones, so you can name a destination without knowing its URL. A tag matching no enabled endpoint is refused here rather than discovered as a webhook that never fired. |
auto_approve_coverage | number | The fraction of the search you will accept without being asked, between 0 and 1. A plan that reaches it starts straight away; one that falls short waits for you, and its entry says how far short it fell. Absent means every plan waits, which is the default because committing tens of thousands of credits on your behalf should be something you asked for. |
credit_budget | number | The most an automatic approval may commit to. "Get me 80%" and "spend at most 30,000" are two ends of the same decision, and where both are set both must hold. It needs auto_approve_coverage beside it - on its own it would bound nothing, so it is a 400 rather than a field that silently does nothing. It does not cap a plan you approve yourself: that approval is the decision, and you made it with the quote in front of you. |
Call it #
curl -X POST "https://api.easydata.win/v1/sales/search/deep" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"targets":[{"searchUrl":"https://www.linkedin.com/sales/search/people?query=(filters:List())"}],"max_results":40000,"auto_approve_coverage":0.8}'Response #
{
"data": {
"batch_id": "afec9131-080b-4b96-b3bb-726249e4bbf8",
"operation": "sales.search.deep",
"status": "queued",
"total": 2,
"succeeded": 0,
"failed": 0,
"pending": 2,
"results_available": 0,
"credits_used": 0,
"external_id": "crm-sync-2026-09-01",
"created_at": "2026-09-01T10:00:00Z",
"completed_at": null,
"recommended_poll_ms": 2000
},
"meta": { "requestId": "req_8f2ka91x", "recommendedPollMs": 2000 }
}A result entry #
Once the batch has drained, this is what reading its results gives you.
{
"item_index": 0,
"page": 1,
"input": { "searchUrl": "https://www.linkedin.com/sales/search/people?query=(filters:List())" },
"status": "succeeded",
"credits_used": 2300,
"data": {
"plan": {
"total": 41208,
"reachable": 37940,
"coverage": 0.9207,
"probes": 46,
"requests": 434,
"budget_exhausted": false,
"shards": [
{
"pins": [{ "axis": "FUNCTION", "label": "Function", "value": "25", "text": "Sales" }],
"rows": 1962,
"capped": false
},
{
"pins": [
{
"axis": "COMPANY_HEADCOUNT",
"label": "Company headcount",
"value": "",
"text": "Self-employed, 1-10, 5,001-10,000",
"values": [
{ "value": "A", "text": "Self-employed" },
{ "value": "B", "text": "1-10" },
{ "value": "H", "text": "5,001-10,000" }
]
}
],
"rows": 1968,
"capped": false
},
{
"pins": [
{
"axis": "SENIORITY_LEVEL",
"label": "Seniority",
"value": "",
"text": "not classified",
"excluded": true
}
],
"rows": 412,
"capped": false
}
],
"residuals": [
{
"axis": "FUNCTION",
"label": "Function",
"pins": [],
"rows": 2104
}
]
},
"approved": false,
"harvest_credits": 19400,
"expires_at": "2026-09-23T10:00:12Z"
},
"created_at": "2026-09-21T10:04:11Z"
}Page 1 is always the plan and carries no rows; the shards start at page 2. Every entry after it says which shard it came from, so rows can be attributed without waiting for the run to finish.
A plan expires 48 hours after it is made. The counts it quotes stop describing the search as LinkedIn's population moves, so approving a three-week-old plan would fire its shards at a different search while quoting coverage computed for the old one. Past that, submit again to re-plan.
The probing is charged whether or not you approve. It is real requests to LinkedIn, made to answer the question you asked, and a plan you read and decline has still been produced. It is a small fraction of the run: planning costs roughly one request per shard it produces, and fewer where LinkedIn's own filter counts already answer the question, against the several hundred the harvest takes.
There is no synchronous form. The plan is approved by you, which cannot happen inside a request you are holding open, so /sales/search/deep/sync answers 400 naming this path.
Errors #
| Status | Type | When |
|---|---|---|
| 400 | invalid_request | The body could not be accepted as sent. `error.field` names what was wrong. A field this reference does not list is one of these: an unknown key is refused rather than accepted and ignored, because a typo that is silently dropped is indistinguishable from one that worked. |
| 401 | invalid_api_key | Missing, malformed, revoked or expired key. Never metered. |
| 403 | quota_exhausted | This month's allowance cannot cover the submission. Nothing is reserved and nothing is charged. |
| 403 | email_unverified | Nobody on the account has confirmed their email address, and the account is on the default allowance rather than an agreed one. Open the link sent when the account was created, or request a new one from the console. |
| 429 | rate_limited | Too fast: the request budget for your key, too many batches in flight, too many synchronous requests (per minute or at once), or an agreed hourly spend already met. `message` says which. Carries `Retry-After`. Never metered. |
| 400 | invalid_request | A url that is not a Sales Navigator people search, a credit_budget with no auto_approve_coverage beside it, or a coverage outside 0 to 1. |
Every failure uses the same envelope, and none of them is metered - see Errors.