SpiderIQ
SpiderIQ

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, or uploaded from a file you already have.

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

Four sources are registered. Two buy records; two read a file you upload.

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.

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"
  },
  "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.

Everything in columns is advisory. The proposal is a guess shown for confirmation; the mapping that actually runs is the one you send back at submit time in source.filters.column_mapping.

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.

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.

source.source_kind

string

yes

What shape of source to buy: google_maps or linkedin_company. 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 source.

source.upload

object

no

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

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 come back in the same envelope as a campaign, under a synthetic campaign id of bulk_ followed by the bulk_job_id as 32 hex characters with no dashes. For the response above that is bulk_051c62abef51470aa8f04abdd6b14f90.

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.

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

The same.

The same, except metadata, which carries bulk provenance instead of scrape knobs.

Because the envelope matches, every parser, export and dashboard that already reads campaign output reads bulk output unchanged. 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.