# Your leads were always a source. We finally pointed at them.
- **Status**: published | **Author**: Martin Shein | **Published**: 2026-08-23T19:49:00+00:00
- **Reading time**: 7 min
- **Views**: 41

## Excerpt
Bulk lead sourcing gained a fifth source that buys nothing: the corpus already in your account. Pick a past campaign, or filter 84 fields across everything you own.

Every lead source we had ever built asked the same question: where do we go and buy these people?

Outscraper and Apify buy them from Google Maps. A CSV upload skips the purchase but still starts from a file somebody handed you. Sortlist reads a public directory. Four sources, four ways of pointing outward.

Meanwhile a customer of ours had 205,923 businesses sitting in their own account, and no way to do anything with them in bulk.

That is the thing this release fixes. `internal` is the fifth source, and it points the other way.

## The corpus was always there

Every campaign you run leaves leads behind. Some got their websites crawled and never got their emails verified. Some were sourced in a run where you deliberately turned everything off because you only wanted the addresses that day. Some are eighteen months old and have grown a website since.

They are all still there, in your account, already paid for. Until this week the only way to act on them in bulk was to buy them again from the same provider you bought them from the first time, and let deduplication sort it out.

Now you pick them.

## Two ways to point at your own leads

The first is the one people ask for by name. **Pick a past run.** You remember the campaign, you remember what it did and did not do, and you want to finish it. Choose the campaign, or one job inside it, and it goes.

The second is the one that turned out to matter more. **Query the corpus.** A condition-and-group filter over 84 fields, the shape anyone who has used Airtable already knows: where `has website` is not empty, and `has email` is empty, and city is any of Berlin or Hamburg. Add a condition. Add a group. Change `and` to `or`.

Both end in the same place, and it is worth saying what that place is, because it is the part I would get wrong if I were reading about it instead of building it.

## The filter is what gets saved. Not the leads.

There is a button that says *select all 10,008 in these results*, and behind it is the only interesting engineering decision in the whole feature.

The obvious implementation is that the browser collects ten thousand row ids and posts them. It is obvious and it is wrong twice. It is wrong on correctness, because the list was assembled at 14:02 and the run starts at 14:09, and in between your other jobs finished and changed what the answer should be. It is wrong on isolation, because a request body carrying ten thousand ids is a request body that could carry someone else's ten thousand ids.

So the browser sends the question, never the answer. The filter goes to the server, gets validated against a closed list of fields, and is stored as a selection. At submit time the server re-runs that same validated question against your corpus, applies the gates, and takes what it finds. The only thing that crosses the boundary is an id the server already issued to you.

![Select-all sends the question, not the rows: the browser posts a filter, the server re-runs it at dispatch](https://media.cdn.spideriq.ai/content/cli_5z6zs9k9po75hd4s/general/1787572163-9e5ed108.webp)

The visible consequence is small and honest: your count can move between when you look at it and when you run it, and we say so on the screen. The invisible consequence is that there has never been a code path where your run could read another tenant's leads, because no request on this surface has ever contained a lead.

## Two numbers, and the second one is the product

Every screen here shows you two counts, and the gap between them is the whole reason to build this rather than just re-buying.

**Matched** is how many leads your question describes. **Eligible** is how many of those would actually gain something from the stages you ticked.

A business whose site was crawled last Tuesday gains nothing from crawling it again. It matches your filter perfectly and it is not in the run. On one live account, `has no email` matches 9,887 leads and only 986 of them are eligible once you switch on verification, because the other 8,901 have already been through it.

Selecting five thousand and enriching two hundred is not a bug report. It is what a mature corpus looks like, and the number that matters is the small one, because that is what gets billed, that is what the ceiling is checked against, and that is what determines how long you wait.

![Matched is what your filter describes; eligible is what would actually gain from the stages you chose](https://media.cdn.spideriq.ai/content/cli_5z6zs9k9po75hd4s/general/1787572164-162774cb.webp)

The corollary catches people. Eligibility is measured against the stages, so the moment you tick one more stage the number moves. I watched it happen while writing this: a campaign showing six matched and five eligible went to six and six the instant I added the site-crawl stage, because the lead that had nothing to gain from verification had plenty to gain from a crawl. There is no such thing as "how many eligible leads do I have". Only "how many for these stages".

## The chat box we did not build

The original plan had a natural-language box. Type *"restaurants in Berlin with a website but no email"*, we parse it, we build the filter.

We dropped it, and the reason is the more interesting half of this release.

Your agent is already better at this than any parser we would ship. So instead of writing a language model into the product, we made the API legible to the ones you already have. The field catalogue endpoint returns the 84 fields **and the grammar** — every operator each type accepts, which operators take no value, which take a list, the aliases that normalise, all fourteen refusal codes, and a complete worked example with its English translation sitting next to it.

A Claude or Cursor session reads that once and writes valid filters. It does not guess a column name, because an unlisted field is refused and never reaches the query. It does not need us to have anticipated the sentence.

![The API serves its own grammar, so the agent writes the filter and no parser sits in the product](https://media.cdn.spideriq.ai/content/cli_5z6zs9k9po75hd4s/general/1787572165-75e047f5.webp)

Six CLI commands and seven MCP tools cover the same path, and there is a skill on the marketplace that documents the recipe. The natural-language layer is real. We just do not host it.

## The number I did not expect

One thing worth recording, because it nearly shipped broken.

Counting eligible leads means asking "which of these have no crawl yet", which in SQL is a `NOT EXISTS`. Written in the `WHERE` clause, Postgres plans it as an anti-join and it is fast. Moved into a `count(*) FILTER (...)` — same answer, tidier-looking code — it becomes a correlated subplan that runs once per candidate row.

On the test account that took the count from 42.9 ms to 10,934 ms, past the statement timeout, into a 500 on the confirmation screen. On the largest account, 205,923 businesses, it was not slow, it was never.

![The same question, two query plans: one pass across the set, or the same work repeated once per row](https://media.cdn.spideriq.ai/content/cli_5z6zs9k9po75hd4s/general/1787572167-fa9f29a4.webp)

The fix restored the anti-join and the numbers came back: 20 ms for a single stage on the test account, 450 ms on the large one. What makes it worth writing down is that a plan-shape test already existed and passed the entire time, because it explained a different query than the one that regressed. A test that asserts on a query plan has to name which builder's output it is explaining. Ours did not, so it guarded the wrong statement while the right one got 255 times slower.

## What it costs

Nothing at the source, because nothing is bought. But free at the source is not free: the enrichment still runs, once per eligible lead, at the same per-stage price as any other run. The screen prices it on eligible, not matched, which is why the distinction gets the space it does above.

And one thing to leave switched off unless you mean it. VayaPin publishes a permanent public profile page for every lead in the run, and deleting the run does not delete those pages.

---

Live now on the dashboard under **Flows → Bulk Lead Sourcing → Your existing leads**, on the API, and through the CLI, the MCP tools and the skill. The [how-to is here](https://spideriq.ai/docs/lead-generation/enrich-leads-you-already-have) and the [API reference is here](https://spideriq.ai/docs/lead-generation/bulk-lead-sourcing-api).
