SSpiderIQ
SSpiderIQ

Docs / lead-generation/bulk-lead-sourcing-api

Bulk Lead Sourcing API

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.

provider

Kind

source_kinds

Costs money at the source

outscraper

Provider job

google_maps

Yes. No unit price is configured, so estimated_cost_usd is null.

apify

Provider job

google_maps

Yes, billed per record. estimated_cost_usd returns a real number.

csv

Upload

google_maps

No.

json

Upload

google_maps

No.

sortlist

Public directory

sortlist_agency

No. Agencies from a public B2B directory. Not eligible for the vayapin stage.

internal

Your own corpus

unenriched_run, corpus_query

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.

  1. 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.

  2. **country_code** does not place a search. It is a locale hint. Only geo[].label, appended as "{query}, {label}", or an explicit latitude and longitude steers 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

available

Whether this source can be selected right now.

unavailable_reason

Why not, when available is false. null otherwise.

requires_upload

true means you must upload a file first and submit its upload_id. false means you send queries and geo.

Errors:

Status

Reason code

When

How to resolve

401

unauthenticated

Credentials are missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

403

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

record_count

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.

expires_at

7 days from upload. An unconsumed file is cleaned up.

columns

Present for csv, absent for json. See below.

Errors:

Status

Reason code

When

How to resolve

413

UPLOAD_TOO_LARGE

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.

422

UPLOAD_UNPARSEABLE

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.

422

UNKNOWN_UPLOAD_SOURCE

provider is not a registered upload source.

Use csv or json. Check GET /sources.

401

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:

source_kind

The question

Narrowed by

unenriched_run

"That campaign I ran in June never got its websites crawled."

campaign_id, or a single job_id inside it

corpus_query

"Every lead with a website but no email."

a filter AST over 84 fields

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

matched_leads

How many leads your question describes.

eligible_leads

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

stages

string[]

yes

The stages the new run would enable. Eligibility is measured against these.

kind

string

no

campaign or job. Defaults to campaign-level rollups.

campaign_id

string

no

List the jobs inside one campaign instead of all campaigns.

exclude_without_website

boolean

no

Drop leads with no website at all, which a crawl cannot help. Defaults to true.

limit

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

401

unauthenticated

Credentials missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

422

validation error

stages omitted, or a stage name that is not gateable.

Send at least one of spidersite, spiderverify, vayapin, social_media_enrichment.

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

grammar

The tree shape: a root group, groups nesting groups or conditions, mixed freely.

boolean_operators

and, or.

operators_by_type

Which operators each field type accepts. Agrees with every field's own operators list.

valueless_operators

is_empty, is_not_empty. Sending a value with these is refused.

list_operators

has_all_of, has_any_of, has_none_of, is_any_of, is_none_of. These take an array.

operator_aliases

Convenience spellings that normalise, such as = to eq and has any of to has_any_of.

value_rules

Per-type value constraints.

limits

The same budget as the top-level limits.

error_codes

All fourteen refusal codes, listed below.

example

A complete, valid filter you can post as-is.

example_reads_as

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

401

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

field

string

yes

A key from the catalogue.

q

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

401

unauthenticated

Credentials missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

422

unknown_field

field is not a catalogue key.

Read fields[].key from the catalogue.

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

stages

string[]

yes

The stages the run would enable. eligible_leads is measured against these.

source_kind

string

no

corpus_query, the default, or unenriched_run.

filter

object

no

The filter AST. Omit it to count the whole corpus.

campaign_id

string

no

For unenriched_run.

job_id

string

no

For unenriched_run, one job inside a campaign.

exclude_without_website

boolean

no

Defaults to true.

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

401

unauthenticated

Credentials missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

422

unknown_field

field is not a catalogue key.

Read fields[].key from the catalogue.

422

invalid_operator

The operator is not one this field's type accepts.

Check ast.operators_by_type, or the field's own operators.

422

operator_not_allowed

A valid operator, wrong field type. Comparing a city with >.

Use an operator from that field's list.

422

unexpected_value

A value was sent with is_empty or is_not_empty.

Drop the value.

422

missing_value

An operator that needs a value did not get one.

Supply value.

422

invalid_value

The value does not match the field's type, such as a non-uuid for a uuid field.

Check ast.value_rules.

422

value_too_long

A single value exceeds the length cap.

Shorten it.

422

list_too_long

More than 100 values in one list operator.

Split the condition.

422

too_many_conditions

More than 20 conditions.

Simplify, or run two selections.

422

too_many_groups

More than 5 groups.

Flatten a level.

422

too_deep

Nesting past depth 3.

Flatten a level.

422

invalid_group_operator

A group op that is not and or or.

Use and or or.

422

malformed_node

A node that is neither a group nor a condition. filter: {} is refused for this reason.

Send a group with an op and a conditions array, or omit filter.

422

unknown_condition_key

An unrecognised key on a condition object.

A condition takes field, operator and value only.

422

validation error

stages omitted.

stages is required and never defaults.

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

cursor

string

no

Keyset cursor from the previous page. Not an offset.

sort

string

no

Sort order for the page.

limit

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

source_kind

string

yes

unenriched_run or corpus_query.

stages

string[]

yes

The stages the new run will enable. Eligibility is scoped to these.

campaign_id

string

conditional

Required for unenriched_run unless job_id is given.

job_id

string

no

One job, one location, inside a campaign.

filter

object

no

The filter AST, for corpus_query. Same shape and same validator as corpus/count.

exclude_without_website

boolean

no

Defaults to true.

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" } ] }
  }'

Response201 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

401

unauthenticated

Credentials missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

404

not found

The selection_id belongs to another tenant, or does not exist.

Both cases return the same body on purpose. A 403 would confirm the id exists.

422

filter refusal

Any of the fourteen codes in the corpus/count table.

Same resolutions.

422

validation error

source_kind is unenriched_run with neither campaign_id nor job_id.

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

is_upload

The source is a file, not a provider job.

source_is_free

Nothing is spent acquiring the records. Use this, not has_cost, to decide whether to show a purchase confirmation.

record_count_known

The exact record count is known ahead of the run. True for uploads; false for a provider job, where estimated_records is an upper bound.

enrichment_jobs

Leads times enabled stages. This is what an upload actually costs you.

would_exceed_cap

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

401

unauthenticated

Credentials are missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

422

schema validation

A field failed validation, identical to submit.

The response names the field. Fix it and call again.

422

stage ineligible

settings.workflow enabled a stage this source_kind cannot serve.

Disable that stage, or change source_kind.

404

unknown upload

source.upload.upload_id does not exist, is expired, or belongs to another tenant.

Upload the file again and use the new upload_id.

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

source

object

yes

The provider-neutral buy order.

source.provider

string

yes

Registered lead-source adapter, 1 to 64 characters: outscraper, apify, csv, json, sortlist, internal. Resolved against the registry at request time, so it is deliberately not an enum.

source.source_kind

string

yes

What shape of source to ask for: google_maps, linkedin_company, sortlist_agency, unenriched_run or corpus_query. Bounds which downstream stages are eligible.

source.queries

string[]

yes

Bare search terms, 1 to 1,000 entries. The effective search list is queries x geo labels. Send [] for an upload or internal source, which has no searches.

source.upload

object

no

{ "upload_id": "..." } from the upload route. Required for csv and json; forbidden for a provider.

source.selection

object

no

{ "selection_id": "..." } from the selections route. Required for internal; forbidden elsewhere. Mutually exclusive with queries and upload. The filter itself is never accepted here.

source.geo

object[]

no

Geographic targets, up to 1,000 entries. Each carries label, latitude, longitude, country_code, region. A labelled entry multiplies the query list; a coordinate-only entry steers the search centre. Not used by an upload.

source.limits.max_records_per_query

integer

no

Records per expanded query, 1 to 100,000. Omitted falls back to 500.

source.limits.max_total_records

integer

no

Ceiling on total records for the whole job, 1 to 1,000,000.

source.filters

object

no

Provider-specific knobs. For csv this carries column_mapping, an object of field -> your header. The adapter refuses any key that would overwrite a field this request already owns.

source.language

string

no

Result language, 2 to 8 characters. Defaults to en.

settings.workflow

object

no

Which downstream stages each sourced lead runs: spidersite, spiderverify, vayapin, social_media_enrichment, smartlead. This is the same WorkflowConfig a campaign uses, reused verbatim.

priority

integer

no

Queue priority for the fanned-out leads, 0 to 10. Defaults to 5.

test

boolean

no

Route to test queues; no production side effects. Defaults to false.

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 bought

Example — 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 100

From 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

bulk_job_id

The manifest handle: provenance, counts, artifact digest.

job_id

The parent job. Poll this for progress.

estimated_queries

Concrete search strings after queries x geo expansion. 0 for an upload.

estimated_records

Upper-bound record count the budget guard was evaluated against. For an upload this is the exact counted total.

estimated_cost_usd

estimated_records times the provider unit cost. A null means no unit cost is configured, which does not mean free — the record ceiling is then the only guard on the run. For an upload it is null because the source genuinely costs nothing.

Errors:

Status

Reason code

When

How to resolve

422

schema validation

A field failed validation, including an upload source sent without source.upload, or a provider sent with one.

The response names the field. Fix it and resubmit.

422

expansion ceiling

queries x geo expanded past 1,000 concrete queries.

Split the run into several submissions.

422

stage ineligible

settings.workflow enabled a stage this source_kind cannot serve.

Disable that stage, or change source_kind. See the eligibility table below.

422

column mapping

The mapping named a column not present in the file, or resolved no identifying column.

Re-read columns from the upload response and map at least one of name, place_id, google_place_id, website, phone.

429

bulk_records_per_job_exceeded

Estimated records above your per-job ceiling, 25,000 by default.

Narrow the run. This response carries no Retry-After by design: the run is too big and stays too big, so retrying it unchanged fails identically forever.

429

bulk_estimated_spend_exceeded

Projected 24-hour spend would cross your cost ceiling.

Wait, or narrow the run. This one does carry Retry-After, because the window rolls.

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);                      // 2

What the parent's fields mean.

Field

Meaning

screening.provider_delivered

Records the provider actually returned, before any filtering of yours.

screening.kept

Records that survived your filters and were fanned out for enrichment.

screening.dropped

Records rejected before fan-out.

screening.drop_reasons

Reason to count, e.g. {"too_few_reviews": 20}.

cost.cost_usd

What the run spent at the source.

cost.source_is_free

Nothing was bought to obtain these records. Branch your cost messaging on this, never on a null figure.

cost.estimated_cost_usd

null means no unit price is configured for the provider. It does not mean free.

children.job_ids

One job id per kept lead. Read each one through results_endpoint_template.

children.job_ids_truncated

true when the run fanned out more than 100 leads and the list was capped. Use workflow_results_endpoint for the whole run.

children.workflow_results_endpoint

A per-stage roll-up across every child, including workflow_progress.sites_completed.

enriched_count / not_enriched_count

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 job_id is wrong, or the job belongs to another tenant.

Use the job_id the submit returned. A bulk_job_id is not a job id and has no job route.

401

unauthenticated

Credentials are missing or wrong.

Send a valid Bearer triple, PAT, or session cookie.

422

string_pattern_mismatch

You sent ?format=json.

JSON is the default. Send no format param at all; only yaml and md are accepted values.

200, data.screening absent

The run failed before it stored a summary.

Read GET /jobs/{job_id}/status; a failed run carries an error_message.

Do not list a bulk run's children through **GET /api/v1/jobs/spiderMaps/campaigns/{campaign_id}/jobs**. It answers 200 with a correct total and an empty jobs array for every bulk campaign, because a bulk child is a per-lead job and carries no location. Use children.job_ids, which is why it is inlined.

Stage eligibility depends on the source kind

Source kind

Eligible stages

google_maps

All: site, verify, vayapin, social enrichment, smartlead.

linkedin_company

Site, verify, social enrichment, smartlead. Never vayapin.

sortlist_agency

Site, verify, social enrichment, smartlead. Never vayapin.

unenriched_run

All, bounded by what each lead still lacks.

corpus_query

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 data.children.job_ids.

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}/status and GET /api/v1/jobs/{job_id}/results.