Source leads in bulk, then run every lead through the same enrichment pipeline a campaign uses. The records can be bought from a data provider in one job covering many search terms across many locations, uploaded from a file you already have, or selected from the leads already in your account.
Use this instead of a campaign when you want breadth in a single purchase. A campaign runs one Google Maps search per location; bulk buys every query x location combination at once.
POST /bulk-lead-sourcing/submit -> 202 (manifest written, provider NOT called)
| bulk worker
submit -> poll -> fetch -> parse -> dedup -> fan-out
| one job per lead
SpiderSite -> SpiderVerify -> VayaPin (identical to a campaign)The provider is not contacted during this request. A manifest row is created and 202 Accepted returns immediately with status: "pending". The bulk worker drives the run. A 202 means accepted and gated, not bought.
Where records come from
Six sources are registered. Two buy records, two read a file you upload, and two cost nothing at the source.
| Kind |
| Costs money at the source |
|---|---|---|---|
| Provider job |
| Yes. No unit price is configured, so |
| Provider job |
| Yes, billed per record. |
| Upload |
| No. |
| Upload |
| No. |
| Public directory |
| No. Agencies from a public B2B directory. Not eligible for the vayapin stage. |
| Your own corpus |
| No. Selects leads you already have; see below. |
Do not hardcode this list. GET /sources resolves it from the adapter registry at request time, so a source added on the backend appears without a client change.
An upload is free at the source, which is not the same as free. You still pay for each enrichment stage you switch on, once per lead. Branch your cost messaging on source_is_free, never on has_cost — an unpriced provider like outscraper looks identical to a free upload on every money field.
Two traps that cost real money
Neither of these errors. Both look like a clean success. They apply to the provider sources only; an upload has no queries and no geo.
Omitting
**limits.max_records_per_query**buys 500 records per search. Two queries across three cities reads as "6 searches" and is a 3,000-record purchase. The platform default is 500 records per expanded query. Quote yourself records, never searches.**country_code**does not place a search. It is a locale hint. Onlygeo[].label, appended as"{query}, {label}", or an explicitlatitudeandlongitudesteers where the provider looks. A target carrying only{"country_code": "US"}buys a nationwide set of genuinely valid businesses that nobody asked for.
GET /api/v1/dashboard/bulk-lead-sourcing/sources
Every source, with availability resolved live from the adapter registry.
curl "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/sources" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"Response
{
"sources": [
{
"provider": "csv",
"label": "CSV upload",
"description": "Bring your own records. No provider, no spend at the source.",
"source_kinds": ["google_maps"],
"available": true,
"unavailable_reason": null,
"requires_upload": true
}
]
}Field | Meaning |
|---|---|
| Whether this source can be selected right now. |
| Why not, when |
|
|
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials are missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
| not tenant scoped | Authenticated, but the caller resolves to no tenant. | Use a client PAT, or select a brand on the session. |
This route has no other failure mode. An adapter that vanishes between listing and lookup is skipped rather than erroring, so the list is always answerable.
POST /api/v1/dashboard/bulk-lead-sourcing/upload
Store a records file and read back what is in it. This is not a submit. Nothing is enriched, nothing is queued, nothing is spent. The file sits in storage for 7 days and you can abandon it.
multipart/form-data with three parts: provider (csv or json), source_kind, and file.
curl -X POST "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/upload" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-F "provider=csv" \
-F "source_kind=google_maps" \
-F "file=@my-leads.csv"Response 201 Created
{
"upload_id": "97a3669f-3c2e-4a1b-9d55-0f2b7c8e4d10",
"provider": "csv",
"source_kind": "google_maps",
"filename": "my-leads.csv",
"size_bytes": 4182,
"sha256": "bae968a5...f3712",
"record_count": 3,
"expires_at": "2026-08-20T09:14:00Z",
"columns": {
"columns": ["Firma", "Str.", "Ort", "PLZ", "Tel", "Homepage"],
"proposed_mapping": { "phone": "Tel", "website": "Homepage" },
"unmapped_columns": ["Firma", "Str.", "Ort", "PLZ"],
"delimiter": ";",
"encoding": "cp1252",
"mappable_fields": ["name", "place_id", "google_place_id", "address", "city", "..."],
"identifying_fields": ["name", "place_id", "google_place_id", "website", "phone"]
},
"message": "Stored bulk upload"
}Field | Meaning |
|---|---|
| Rows counted by reading the artifact back out of storage, so a truncated write is caught here rather than as an unexplained short run. This is what the record ceiling is applied to. |
| 7 days from upload. An unconsumed file is cleaned up. |
| Present for |
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
|
| The file is over 64 MiB. | Split the file. A file over roughly 96 MB is refused earlier still, by the edge, as raw HTML rather than JSON. |
|
| The file did not parse, or parsed to zero records: a CSV with a header and no data rows, a first line of only separators, or JSON in a shape with no record array. | Fix the file. No row is written and the stored object is deleted, so a failed upload leaves nothing behind. |
|
|
| Use |
| unauthenticated | Credentials are missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
Reading the column proposal, and why the mapping is yours to confirm
A CSV has arbitrary headers, so the pipeline cannot assume a shape. The upload response reports the header row in file order, the detected delimiter and text encoding, and a proposed mapping.
The proposal in columns is advisory: it is a guess shown for confirmation, and the mapping that actually runs is the one you send back at submit time in source.filters.column_mapping.
mappable_fields and identifying_fields are the exception: they are not a guess, they are the server's own vocabulary, spliced in at response time. Render the mapping panel from mappable_fields rather than from a list kept by hand in the client, or the panel silently loses every field the server gains later. An empty identifying_fields means the server predates the field, not that nothing identifies a business.
A run needs at least one identifying column mapped: name, place_id, google_place_id, website, or phone. With none of them every row is an anonymous bag of attributes, deduplication collapses the file to a single lead, and the enrichment stages have nothing to look up. The mapping is refused at the door, before anything is spent.
There is deliberately no email or contact target to map onto. A client can name a column anything, so a deny-list cannot work; the guarantee is that no mapping target for contact data exists to select. Contact data is produced by the verification stages, never accepted from the file.
Enriching leads you already own
The third origin. internal sources no records at all — it selects leads already in your account and sends them through the enrichment pipeline again, so a lead you paid to find last month can be crawled, verified or pinned this month without being bought a second time.
It has two source_kinds, and they answer different questions:
| The question | Narrowed by |
|---|---|---|
| "That campaign I ran in June never got its websites crawled." |
|
| "Every lead with a website but no email." | a |
Both resolve through one code path, and both end at the same place: a selection, a saved, tenant-scoped, seven-day snapshot of a question. You submit the selection's id. You never submit the question.
GET corpus/fields -> the 84 fields AND the filter grammar
POST corpus/count -> matched_leads + eligible_leads (free, fires on every keystroke)
POST selections -> 201 { selection_id } (saves the question)
POST submit -> { source: { selection: { selection_id } } }Matched is not eligible, and the difference is what you pay for
Every counting route returns two numbers, and reading the wrong one is the single most expensive mistake on this surface.
Means | |
|---|---|
| How many leads your question describes. |
| How many of those would actually gain something from the stages you enabled. |
A lead whose website is already crawled gains nothing from another crawl. On a mature corpus the gap is not a rounding error — a live tenant returns 9,887 matched and 986 eligible for has.email is_empty with a verify stage enabled. Selecting 5,000 and enriching 200 is the ordinary reading, not a bug.
eligible_leads is what the record ceiling is checked against, what the run costs, and what it will take to finish. Quote it. matched_leads is context and nothing in the system acts on it.
Eligibility is scoped to stages, which is why stages is required on every counting route and never defaults. A count taken for one stage set is meaningless for another — never carry a number across.
GET /api/v1/dashboard/bulk-lead-sourcing/past-runs
Past campaigns and jobs that still have something to gain, for the unenriched_run kind.
Parameters
Field | Type | Required | Description |
|---|---|---|---|
| string[] | yes | The stages the new run would enable. Eligibility is measured against these. |
| string | no |
|
| string | no | List the jobs inside one campaign instead of all campaigns. |
| boolean | no | Drop leads with no website at all, which a crawl cannot help. Defaults to |
| integer | no | Maximum runs to return. |
curl -G "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/past-runs" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
--data-urlencode "stages=spidersite" \
--data-urlencode "stages=spiderverify"Response
Each run carries its own two counts plus a label describing what that run originally ran, derived from the campaign's stored workflow config.
{
"runs": [
{
"campaign_id": "camp_us_dentists_20260614...",
"label": "Dentists, 12 locations",
"matched_leads": 1204,
"eligible_leads": 205,
"ran_stages": ["spidermaps"]
}
]
}A campaign's stage config is opt-out: a campaign stored with no workflow config ran the full pipeline, not an empty one. The label reflects that, so a run you remember as "maps only" may correctly report that it also verified.
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
| validation error |
| Send at least one of |
GET /api/v1/dashboard/bulk-lead-sourcing/corpus/fields
The field catalogue and the filter grammar, in one response. This is the route an agent reads before it writes anything.
There is no natural-language endpoint on this surface and that is deliberate. The agent is the natural-language layer: it reads this catalogue, translates the user's sentence into a filter tree, and posts the tree. Nothing on the server parses English.
curl "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/corpus/fields" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"Response
Four top-level keys: fields, limits, presets, ast.
{
"fields": [
{
"key": "business.city",
"label": "City",
"type": "text",
"field_class": "direct",
"operators": ["is", "is_not", "contains", "does_not_contain",
"starts_with", "is_empty", "is_not_empty", "is_any_of"]
}
],
"limits": { "max_conditions": 20, "max_depth": 3,
"max_groups": 5, "max_values_per_list": 100 },
"presets": [
{ "key": "never_crawled", "label": "Never crawled",
"help": "Has a website, but SpiderSite has never run on it.",
"filter": { "op": "and", "conditions": [
{ "field": "has.website", "operator": "is_not_empty" },
{ "field": "has.crawl", "operator": "is_empty" } ] } }
],
"ast": { }
}The ast block carries eleven keys and is the whole contract for writing a filter:
Key | What it gives you |
|---|---|
| The tree shape: a root group, groups nesting groups or conditions, mixed freely. |
|
|
| Which operators each field type accepts. Agrees with every field's own |
|
|
|
|
| Convenience spellings that normalise, such as |
| Per-type value constraints. |
| The same budget as the top-level |
| All fourteen refusal codes, listed below. |
| A complete, valid filter you can post as-is. |
| That example in English, so you can check your translation against it. |
The example, shipped in the response and runnable unchanged:
{
"op": "and",
"conditions": [
{ "field": "has.website", "operator": "is_not_empty" },
{ "field": "has.email", "operator": "is_empty" },
{ "op": "or", "conditions": [
{ "field": "business.city", "operator": "is_any_of", "value": ["Berlin", "Hamburg"] },
{ "field": "business.city", "operator": "starts_with", "value": "Mün" }
] }
]
}Reads as: has a website AND has no email yet AND (is in Berlin or Hamburg OR its city starts with Mün).
Build from the catalogue, never from a column name. field must be a key from the fields array in the same response. An unlisted key is refused as unknown_field and is never interpolated into SQL, not even to report the error.
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
The catalogue itself cannot fail on input. It takes no parameters.
GET /api/v1/dashboard/bulk-lead-sourcing/corpus/values
Distinct values for one field, for populating a picker.
Parameters
Field | Type | Required | Description |
|---|---|---|---|
| string | yes | A |
| string | no | Substring filter on the returned values. |
curl -G "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/corpus/values" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
--data-urlencode "field=business.city" \
--data-urlencode "q=ber"Response
{
"field": "business.city",
"values": [
{ "value": "Berlin", "count": 1204 },
{ "value": "Bergamo", "count": 87 }
]
}Feed a value straight back into a condition using is or is_any_of.
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
|
|
| Read |
POST /api/v1/dashboard/bulk-lead-sourcing/corpus/count
Both counts for a filter, saving nothing. Free, and cheap enough to fire while the user is still typing.
Parameters
Field | Type | Required | Description |
|---|---|---|---|
| string[] | yes | The stages the run would enable. |
| string | no |
|
| object | no | The filter AST. Omit it to count the whole corpus. |
| string | no | For |
| string | no | For |
| boolean | no | Defaults to |
curl -X POST "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/corpus/count" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"source_kind": "corpus_query",
"stages": ["spidersite"],
"filter": {
"op": "and",
"conditions": [
{ "field": "has.website", "operator": "is_not_empty" },
{ "field": "has.crawl", "operator": "is_empty" }
]
}
}'Response
{ "matched_leads": 10064, "eligible_leads": 9862 }Both numbers come from one statement against one snapshot of the corpus, so they always describe the same instant.
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
|
|
| Read |
|
| The operator is not one this field's type accepts. | Check |
|
| A valid operator, wrong field type. Comparing a city with | Use an operator from that field's list. |
|
| A | Drop the |
|
| An operator that needs a value did not get one. | Supply |
|
| The value does not match the field's type, such as a non-uuid for a uuid field. | Check |
|
| A single value exceeds the length cap. | Shorten it. |
|
| More than 100 values in one list operator. | Split the condition. |
|
| More than 20 conditions. | Simplify, or run two selections. |
|
| More than 5 groups. | Flatten a level. |
|
| Nesting past depth 3. | Flatten a level. |
|
| A group | Use |
|
| A node that is neither a group nor a condition. | Send a group with an |
|
| An unrecognised key on a condition object. | A condition takes |
| validation error |
|
|
POST /api/v1/dashboard/bulk-lead-sourcing/corpus/leads
A page of the leads a filter matches, so the user can see what they are about to enrich. Same body as corpus/count, plus paging.
Parameters
Field | Type | Required | Description |
|---|---|---|---|
| string | no | Keyset cursor from the previous page. Not an offset. |
| string | no | Sort order for the page. |
| integer | no | Page size. |
curl -X POST "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/corpus/leads?limit=2" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"source_kind": "corpus_query",
"stages": ["spidersite"],
"filter": {
"op": "and",
"conditions": [
{ "field": "business.city", "operator": "is", "value": "Berlin" }
]
}
}'Response
{
"leads": [
{ "business_id": "b7f1...", "name": "Cafe Kranzler",
"city": "Berlin", "website": "https://cafe-kranzler.example" }
],
"next_cursor": "eyJpZCI6..."
}Page forward by sending next_cursor back as cursor. Paging is keyset, not offset, so a corpus that grows while you page will not hand you the same row twice.
Rows carry business identity only. No email, contact or phone field is ever returned here, even though the eligibility gates read those columns to decide what still needs work.
Errors: the same fourteen filter refusals as corpus/count, plus 401.
POST /api/v1/dashboard/bulk-lead-sourcing/selections
Save a question. Returns a selection_id you can submit.
Parameters
Field | Type | Required | Description |
|---|---|---|---|
| string | yes |
|
| string[] | yes | The stages the new run will enable. Eligibility is scoped to these. |
| string | conditional | Required for |
| string | no | One job, one location, inside a campaign. |
| object | no | The filter AST, for |
| boolean | no | Defaults to |
curl -X POST "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/selections" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"source_kind": "corpus_query",
"stages": ["spidersite"],
"filter": { "op": "and", "conditions": [
{ "field": "has.website", "operator": "is_not_empty" },
{ "field": "has.crawl", "operator": "is_empty" } ] }
}'Response — 201 Created
{
"selection_id": "8db896d0-...",
"source_kind": "corpus_query",
"matched_leads": 10064,
"eligible_leads": 9862,
"expires_at": "2026-08-30T12:00:00Z"
}A selection expires after seven days and is scoped to the tenant that created it.
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
| not found | The | Both cases return the same body on purpose. A |
| filter refusal | Any of the fourteen codes in the | Same resolutions. |
| validation error |
| Supply one. |
Submitting an internal run
The selection travels alone. source.selection is an id and nothing else, and the third arm is mutually exclusive with queries and upload.
curl -X POST "https://spideriq.ai/api/v1/bulk-lead-sourcing/submit" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"source": {
"provider": "internal",
"source_kind": "corpus_query",
"queries": [],
"selection": { "selection_id": "8db896d0-..." }
},
"settings": { "workflow": {
"spidersite": { "enabled": true },
"vayapin": { "enabled": false }
} }
}'The filter is never accepted on submit. Sending one is a 422 with extra_forbidden. This is not tidiness: a body that could carry a predicate could carry someone else's predicate, so the only thing that crosses the submit boundary is an id the server already validated and already scoped to your tenant.
The run resolves the selection at dispatch, snapshots the eligible leads, and fans out one job per lead into the same pipeline every other source uses. The result envelope is byte-identical to a campaign's.
POST /api/v1/dashboard/bulk-lead-sourcing/estimate
Size a run without committing to it. Writes nothing, contacts no provider, consumes no quota. Asking is not buying, which is exactly why this is a separate route from submit: submit returns its estimate only after the manifest is written.
Takes the same body as submit.
curl -X POST "https://spideriq.ai/api/v1/dashboard/bulk-lead-sourcing/estimate" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"source": {
"provider": "csv",
"source_kind": "google_maps",
"queries": [],
"upload": { "upload_id": "97a3669f-3c2e-4a1b-9d55-0f2b7c8e4d10" }
},
"settings": { "workflow": { "spidersite": { "enabled": true }, "spiderverify": { "enabled": true } } }
}'Response
{
"provider": "csv",
"source_kind": "google_maps",
"estimated_queries": 0,
"estimated_records": 3,
"estimated_cost_usd": null,
"has_cost": false,
"records_cap": 25000,
"would_exceed_cap": false,
"is_upload": true,
"source_is_free": true,
"record_count": 3,
"record_count_known": true,
"enrichment_stages": ["spidersite", "spiderverify"],
"enrichment_jobs": 6,
"upload_filename": "my-leads.csv"
}Field | Meaning |
|---|---|
| The source is a file, not a provider job. |
| Nothing is spent acquiring the records. Use this, not |
| The exact record count is known ahead of the run. True for uploads; false for a provider job, where |
| Leads times enabled stages. This is what an upload actually costs you. |
| Advisory. The authoritative refusal is still the dispatcher's at submit; this runs the same comparison early so the user finds out before the confirm button rather than after it. |
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| unauthenticated | Credentials are missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
| schema validation | A field failed validation, identical to submit. | The response names the field. Fix it and call again. |
| stage ineligible |
| Disable that stage, or change |
| unknown upload |
| Upload the file again and use the new |
There is no 429 here. Nothing is reserved and nothing is bought, so there is nothing to refuse. would_exceed_cap reports what submit would do rather than refusing on its behalf.
POST /api/v1/bulk-lead-sourcing/submit
Submit one bulk buy order. Returns 202 Accepted with a manifest handle; the purchase happens afterwards, in the worker.
Authenticate with Authorization: Bearer <client_id>:<api_key>:<api_secret>.
A dashboard-scoped twin lives at POST /api/v1/dashboard/bulk-lead-sourcing/submit. It accepts a session cookie or a PAT and runs the same gate through the same execute_submission call. Two doors onto one irreversible spend do not mean two gates.
Parameters
Field | Type | Required | Description |
|---|---|---|---|
| object | yes | The provider-neutral buy order. |
| string | yes | Registered lead-source adapter, 1 to 64 characters: |
| string | yes | What shape of source to ask for: |
| string[] | yes | Bare search terms, 1 to 1,000 entries. The effective search list is |
| object | no |
|
| object | no |
|
| object[] | no | Geographic targets, up to 1,000 entries. Each carries |
| integer | no | Records per expanded query, 1 to 100,000. Omitted falls back to 500. |
| integer | no | Ceiling on total records for the whole job, 1 to 1,000,000. |
| object | no | Provider-specific knobs. For |
| string | no | Result language, 2 to 8 characters. Defaults to |
| object | no | Which downstream stages each sourced lead runs: |
| integer | no | Queue priority for the fanned-out leads, 0 to 10. Defaults to 5. |
| boolean | no | Route to test queues; no production side effects. Defaults to |
Example — a provider job
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const run = await client.bulkLeadSourcing({
source: {
provider: "apify",
source_kind: "google_maps",
queries: ["restaurants", "cafes"],
geo: [
{ label: "Atlanta, Georgia, USA", country_code: "US" },
{ label: "Savannah, Georgia, USA", country_code: "US" },
],
limits: { max_records_per_query: 100 },
},
settings: {
workflow: {
spidersite: { enabled: true },
spiderverify: { enabled: true },
vayapin: { enabled: false },
},
},
});
console.log(run.estimated_records); // → 400, across 4 expanded queries
console.log(run.estimated_cost_usd); // → 1.6 on apify; null on outscraper
// a 429 here means a guard refused the run BEFORE anything was boughtExample — an uploaded CSV
Upload first, confirm the mapping, then submit the upload_id with the mapping you confirmed.
curl -X POST "https://spideriq.ai/api/v1/bulk-lead-sourcing/submit" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET" \
-H "Content-Type: application/json" \
-d '{
"source": {
"provider": "csv",
"source_kind": "google_maps",
"queries": [],
"upload": { "upload_id": "97a3669f-3c2e-4a1b-9d55-0f2b7c8e4d10" },
"filters": {
"column_mapping": {
"name": "Firma",
"address": "Str.",
"city": "Ort",
"postal_code": "PLZ",
"phone": "Tel",
"website": "Homepage"
}
}
},
"settings": { "workflow": { "spidersite": { "enabled": true } } }
}'The mapping keys are our field names; the values are your headers, byte for byte as the upload reported them. Do not normalise a header before sending it back.
From the CLI:
spideriq bulk-source submit \
-q restaurants -q cafes \
-g "Atlanta, Georgia, USA" -g "Savannah, Georgia, USA" \
--max-records 100From an MCP client:
submit_bulk_lead_sourcing(
provider="apify",
source_kind="google_maps",
queries=["restaurants", "cafes"],
geo=[{"label": "Atlanta, Georgia, USA"}, {"label": "Savannah, Georgia, USA"}],
max_records_per_query=100
)The CLI and MCP surfaces cover the provider sources. An upload needs the multipart call above first, which the dashboard drives.
Response
{
"bulk_job_id": "051c62ab-ef51-470a-a8f0-4abdd6b14f90",
"job_id": "7ae3b20d-a9ee-4601-a6ed-317885f6a3aa",
"provider": "apify",
"source_kind": "google_maps",
"status": "pending",
"estimated_queries": 4,
"estimated_records": 400,
"estimated_cost_usd": 1.6,
"message": "Bulk lead sourcing accepted"
}Field | Meaning |
|---|---|
| The manifest handle: provenance, counts, artifact digest. |
| The parent job. Poll this for progress. |
| Concrete search strings after |
| Upper-bound record count the budget guard was evaluated against. For an upload this is the exact counted total. |
|
|
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
| schema validation | A field failed validation, including an upload source sent without | The response names the field. Fix it and resubmit. |
| expansion ceiling |
| Split the run into several submissions. |
| stage ineligible |
| Disable that stage, or change |
| column mapping | The mapping named a column not present in the file, or resolved no identifying column. | Re-read |
|
| Estimated records above your per-job ceiling, 25,000 by default. | Narrow the run. This response carries no |
|
| Projected 24-hour spend would cross your cost ceiling. | Wait, or narrow the run. This one does carry |
Both 429s are evaluated before the manifest is written and before anything is purchased, so a denial leaves nothing behind and charges nothing. The record ceiling applies to an upload exactly as it does to a purchase, because an upload's records are counted at upload time.
Checking progress
There is no bulk-specific status route. Poll the parent job with the job_id the submit returned:
curl "https://spideriq.ai/api/v1/jobs/7ae3b20d-a9ee-4601-a6ed-317885f6a3aa/status" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"The manifest walks these states:
pending -> submitted -> polling -> ready -> fetching -> parsing -> fanning_out
-> enriching -> completed
(or partial, or failed, or cancelled)A polling state lasting hours is normal: the provider is running your searches.
enriching means the leads are fanned out and the downstream pipeline is still working. It is not terminal, and this is the point: sourcing successfully is not the same as enriching successfully. The four terminal states are completed, partial, failed and cancelled.
The results envelope carries enriched_count and not_enriched_count. A run where some leads enriched and some did not is partial; a run where none did is failed, not completed.
Results do not come back in the same envelope as a campaign. A bulk run fans out one job per kept lead, so the parent job carries the run's funnel and the leads live on its children. Reading a bulk run is two steps, and the parent hands you the child ids.
GET /api/v1/jobs/{job_id}/results
GET /api/v1/jobs/{job_id}/results on the parent returns what the run did: how many records the provider delivered, how many survived your filters and why the rest did not, what the run spent, and the id of every fanned-out child. It never carries a businesses array.
Step 1 — the parent, for the funnel.
curl "https://spideriq.ai/api/v1/jobs/a582d8e1-5873-4be9-aca9-668a290b412d/results" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"Response, abridged to the three blocks this endpoint added:
{
"success": true,
"type": "bulkLeadSourcing",
"status": "completed",
"data": {
"screening": {
"provider_delivered": 2, "kept": 2, "dropped": 0, "drop_reasons": {}
},
"cost": {
"cost_usd": 0.0, "currency": "USD", "source_is_free": true,
"provider_records": 2, "provider_queries": 1,
"estimated_records": 2, "estimated_cost_usd": null
},
"children": {
"campaign_id": "bulk_dca5160507cf4a698d9f7d80c5320c2c",
"fanned_out_count": 2,
"job_ids": [
"1d169a58-ee06-470c-97a1-6415611de072",
"97c44c7b-1394-4fa7-b7da-25723462f705"
],
"job_ids_truncated": false,
"results_endpoint_template": "/api/v1/jobs/{job_id}/results",
"workflow_results_endpoint": "/api/v1/jobs/spiderMaps/campaigns/bulk_dca5160507cf4a698d9f7d80c5320c2c/workflow-results"
},
"delivered_count": 2, "deduped_count": 2, "fanned_out_count": 2,
"enriched_count": 2, "not_enriched_count": 0, "requested_count": 2,
"provider": "sortlist", "source_kind": "google_maps",
"stages_enabled": ["spidersite"], "enrichment_requested": true,
"bulk_job_id": "…", "campaign_id": "bulk_dca5160507cf4a698d9f7d80c5320c2c",
"flow_path": "…"
}
}Step 2 — each child, for the leads. Substitute a children.job_ids entry into children.results_endpoint_template:
curl "https://spideriq.ai/api/v1/jobs/1d169a58-ee06-470c-97a1-6415611de072/results" \
-H "Authorization: Bearer $CLIENT_ID:$API_KEY:$API_SECRET"A child answers with the ordinary per-lead envelope: data.businesses, data.metadata, data.query, data.results_count. That is where the campaign-shaped result you are looking for actually lives.
The same two steps from the client library:
import { SpiderIQClient } from "@spideriq/core";
const client = new SpiderIQClient({ token: process.env.SPIDERIQ_PAT });
const parent = await client.getJobResults("a582d8e1-5873-4be9-aca9-668a290b412d");
console.log(parent.data.screening.kept); // 2
console.log(parent.data.cost.cost_usd); // 0
const leads = [];
for (const childId of parent.data.children.job_ids) {
const child = await client.getJobResults(childId);
leads.push(...child.data.businesses);
}
console.log(leads.length); // 2What the parent's fields mean.
Field | Meaning |
|---|---|
| Records the provider actually returned, before any filtering of yours. |
| Records that survived your filters and were fanned out for enrichment. |
| Records rejected before fan-out. |
| Reason to count, e.g. |
| What the run spent at the source. |
| Nothing was bought to obtain these records. Branch your cost messaging on this, never on a null figure. |
|
|
| One job id per kept lead. Read each one through |
|
|
| A per-stage roll-up across every child, including |
| Leads that finished the enrichment pipeline, and leads that did not. |
Zero kept is an answer, not a failure.
A completed run reporting kept: 0 with a populated drop_reasons is a run that worked: the provider delivered records and your own screening rejected them. The run below delivered 20 and kept none because every record fell under the review floor.
"screening": {
"provider_delivered": 20, "kept": 0, "dropped": 20,
"drop_reasons": {"too_few_reviews": 20}
}screening absent entirely is what a broken run looks like. A zero with a reason beside it is a working filter, and fanned_out_count: 0 follows from it rather than indicating a fault.
Errors:
Status | Reason code | When | How to resolve |
|---|---|---|---|
404 | job not found | The | Use the |
401 | unauthenticated | Credentials are missing or wrong. | Send a valid Bearer triple, PAT, or session cookie. |
422 | string_pattern_mismatch | You sent | JSON is the default. Send no |
200, | — | The run failed before it stored a summary. | Read |
Do not list a bulk run's children through
**GET /api/v1/jobs/spiderMaps/campaigns/{campaign_id}/jobs**. It answers200with a correcttotaland an emptyjobsarray for every bulk campaign, because a bulk child is a per-lead job and carries no location. Usechildren.job_ids, which is why it is inlined.
Stage eligibility depends on the source kind
Source kind | Eligible stages |
|---|---|
| All: site, verify, vayapin, social enrichment, smartlead. |
| Site, verify, social enrichment, smartlead. Never vayapin. |
| Site, verify, social enrichment, smartlead. Never vayapin. |
| All, bounded by what each lead still lacks. |
| All, bounded by what each lead still lacks. |
A LinkedIn company has no street address and no place_id, so a map pin would be garbage. Enabling an ineligible stage is a 422 at submit, deliberately not a silent skip: a run that quietly did less looks like a success.
linkedin_company appears in the eligibility table although no adapter serves it yet. That is deliberate. The rule is a property of the source kind, not of the adapter, so a future LinkedIn adapter inherits the vayapin exclusion instead of rediscovering it against live data.
How deduplication works
The flat result set is deduplicated on an exact canonical key, in this precedence: place_id, then website domain, then phone, then name plus locality. There is no fuzzy matching.
For an uploaded file the key is derived from the columns you mapped, so mapping a website or phone column gives you meaningfully distinct leads and mapping none of them collapses the file.
The key is scoped to the pair of bulk_job_id and canonical key, so deduplication is per run and does not span runs. A business you sourced last week will be sourced again this week.
How bulk differs from a campaign
Campaign | Bulk | |
|---|---|---|
Purchases | One search per location. | One provider job for the whole set, or no purchase at all for an upload. |
Deduplication | Per location. | Across the whole result set. |
Retry | Per-location retry on a thin result. | None. A thin result is re-bought, not retried. |
Stopping mid-flight | Supported. | Not supported. |
Result envelope | Per-lead results on the campaign job. | A funnel summary on the parent; the per-lead results sit on the fanned-out children, reachable through |
A child job's envelope is byte-identical to a campaign job's, so every parser and export that already reads campaign output reads a bulk child unchanged. What differs is the entry point: you reach those children through the parent's data.children.job_ids instead of through a campaign id. An uploaded record arrives as its own result rather than being re-searched, so its metadata.query is null.
Related
POST /api/v1/campaigns/submit— the per-location alternative.GET /api/v1/jobs/{job_id}/statusandGET /api/v1/jobs/{job_id}/results.