# California Contractor License Search (CSLB) (`ai-lead-gen-tools/us-ca-cslb`) Actor

California contractor licenses from the Contractors State License Board: 245 927 rows with business phone (99.8%), county (96.6%), trade classification (100%), plus status, dates, bonds and workers-comp coverage. Filter by county, trade class, status or last update. No proxy, no key, no login.

- **URL**: https://apify.com/ai-lead-gen-tools/us-ca-cslb.md
- **Developed by:** [Vladimir Chernykh](https://apify.com/ai-lead-gen-tools) (community)
- **Categories:** Lead generation, Business
- **Stats:** 2 total users, 1 monthly users, 33.3% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

from $1.80 / 1,000 company scrapeds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.
Since this Actor supports Apify Store discounts, the price gets lower the higher subscription plan you have.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## California Contractor License Scraper — CSLB

Build a contractor lead list from California's **Contractors State License
Board** export: **245 927 licences**, published as one CSV file by the state
itself. No login, no cookies, no captcha, no proxy.

Every record carries a **business phone number (99.8 %)**, the **county
(96.6 %)** and the **trade classification (100 %)** — the three things you need
to sort, territory and pitch a contractor list. That is why this is the
lead-oriented California actor; for corporate-registry data (officers, agents,
EIN, filing history) use the Secretary of State actor instead.

### What you get

One row per **licence**, in a stable shape — every field is always present, and
what CSLB does not publish is `null` rather than missing.

| Field | Source | Notes |
|---|---|---|
| `name` | `BusinessName` | the name printed on the wall, upper case |
| `legalName` | `FullBusinessName` | the full legal form of the name |
| `businessType` | `BusinessType` | Corporation 70 763, Sole Owner 56 934, Limited Liability 12 720, Partnership 1 614, JointVenture 388 |
| `entityType` | derived from `BusinessType` | `CORPORATION`, `SOLE PROPRIETOR`, `LLC`, … — mapped, see below |
| `documentNumber` | `LicenseNo` | California licence number |
| `phone` (`businessPhone`, `businessPhoneDigits`, `businessPhoneE164`) | `BusinessPhone` | `(925) 383 0487`, `9253830487`, `+19253830487` |
| `county` | `County` | 50 real counties |
| `mailingAddress` | six address columns | `street1`, `city`, `state`, `zip`, `country`; country is blank in the source and filled as `United States` |
| `classifications` | `Classifications(s)` | trade classes, pipe-separated in the source |
| `licenseStatus` | `PrimaryStatus` | `CLEAR`, `Contr Bond Susp`, `Work Comp Susp`, … |
| `isActive` | `PrimaryStatus` | `true` only for `CLEAR` |
| `issueDate`, `expirationDate`, `inactivationDate`, `reactivationDate`, `lastUpdate` | five date columns | ISO 8601 |
| `workersComp`, `bonds` | WC + three bond blocks | insurer, policy, effective/expiry/cancellation dates |
| `discipline`, `pending`, `asbestosRegistration` | four status columns | `false` on a clean licence |
| `sourceUrl` | built from `LicenseNo` | see the caveat below |

Always `null` / empty because CSLB does not publish them:
`ein`, `dbaName`, `statusDate`, `jurisdiction` (fixed to `California`),
`formationDate`, `dissolutionDate`, `principalAddress`, `registeredAgent`,
`officers`, `annualReports`, `lastEvent`.

A licence is **not** an incorporation. `issueDate` is the licence date, not the
company's formation date, so it is delivered as its own field and
`formationDate` stays `null` rather than being filled with a half-truth.

### How to use it

Pick a county, pick trades, run. That is the whole setup.

```json
{
  "counties": ["Los Angeles"],
  "classifications": ["C10", "C36"],
  "activeOnly": true,
  "maxResults": 500
}
```

| Input | Default | Notes |
|---|---|---|
| `licenseScope` | `all` | `all` = 245 927 rows; `master` = 142 422; `personnel` = 155 031 |
| `counties` | `[]` | case-insensitive, e.g. `["Orange"]` |
| `classifications` | `[]` | any-of matching, e.g. `["B", "C10"]` |
| `activeOnly` | `true` | keeps `CLEAR` — 234 021 of 245 927 |
| `statuses` | `[]` | exact statuses, overrides the switch |
| `businessTypes` | `[]` | cut sole proprietors out of a corporate list |
| `requiresPhone` | `false` | removes about 240 records |
| `licenseNumbers` | `[]` | filter while streaming, not a lookup |
| `updatedSinceDays` | `0` | `LastUpdate` cursor for a change feed |
| `incremental` | `false` | skip licence numbers already delivered for this exact query |
| `maxResults` | `500` | hard cap, and the download stops when it is reached |
| `maxScanRows` | `0` | stop before `maxResults` on a rare filter |
| `includeRaw` | `false` | attach the untouched CSV row |
| `downloadTimeoutSecs` | `900` | the full 77.7 MB file took 49 s when re-measured 2026-11-02 |

### Reliability: the export is validated by its body, not by its MIME type

On **2026-11-02** CSLB started serving the identical CSV bytes as
`application/vnd.ms-excel` (it answered `text/csv` when measured on 2026-11-01),
still with `content-disposition: attachment; filename=MasterLicenseData.csv` and
the first line unchanged. An earlier build gated on the Content-Type and failed
**every** automated run in ~20 s without reading a single data row, which had the
Actor flagged *Under maintenance*.

The actor now decides from the response body: the first bytes must start with
`LicenseNo` and the header must match all 52 published columns. A MIME type is
only used to reject an HTML page, and an unexpected label on correct bytes is
logged (`CSLB served the CSV body under a non-CSV Content-Type`) instead of
failing the run. There is also a 240 s wall-clock ceiling on the stream, so a slow
day emits the records selected so far instead of being killed at the platform's
5-minute limit.

### Caveat: do not trust the "master list"

The CSLB portal's own export button hands you a file called
`MasterLicenseData` with **142 422 rows**. Measured 2026-11-01, that file is a
strict subset of the complete export:

```
master (142 422)  ⊂  personnel (155 031)  ⊂  all (245 927)
```

The 103 505 licences present only in the complete file include **98 808 in good
standing** — that is 42 % of the active licences in the state. A lead list built
from the portal's own master file silently misses almost half the market, so
`licenseScope` defaults to `all`.

### Caveat: closed licences are in the same file

Of 245 927 rows: `CLEAR` 234 021, then `Contr Bond Susp`, `Work Comp Susp`,
`Liab Ins Susp`, `Family Sup Susp`, `Susp - No Qualifier`, `SOS Suspension`,
`EMP/WK Bnd Susp` for the rest. `activeOnly` is on by default, so you get
licensed contractors rather than a list of everyone who was ever licensed.

### Caveat: `sourceUrl` is not a permalink you can fetch

`LicenseDetail.aspx` rejects a plain GET with a `302` back to the search page;
the record only opens after a server-side name search. The URL is included so a
human can find the licence, not as a fetchable link. Verification means pasting
the licence number into the CSLB licence search.

### Parsing details worth knowing

- **Classifications are pipe-separated**, not comma-separated:
  `C15| C54| D10` → `["C15", "C54", "D10"]`.
- **Dates are `MM/DD/YYYY`**, and some optional ones carry a *leading space*
  (`" 01/31/2025"`) — parsed, not padded into `null`.
- **The CSV is real CSV**: quotes around names with commas, `""` escapes, and
  quoted newlines. It is parsed by a streaming state machine, not by
  `split(',')`, and it survives a row split across two network chunks.
- **Column order is verified on every run.** If CSLB renames or reorders a
  column, the actor fails loudly instead of writing a dataset full of nulls.

### Cost

**$2 per 1 000 licences**, plus a one-off $0.00005 for starting the run.

Published in the Apify Store as
[ai-lead-gen-tools/us-ca-cslb](https://apify.com/ai-lead-gen-tools/us-ca-cslb).
Rate this card shows: `company-scraped` = $0.002 per record (the primary event,
so the card advertises $2 / 1 000), `apify-actor-start` = $0.00005 — and the
start event is deliberately *not* primary, otherwise the card would advertise
$0.05 / 1 000 while billing per record.

You pay only for records that land in the dataset. The export is streamed and
aborted as soon as `maxResults` is reached, so a 500-record run reads a fraction
of the file instead of all 245 927 rows — the cap is a cost control, not just a
convenience.

### Notes

- Data comes straight from `cslb.ca.gov`; no proxy is used, so no proxy cost.
- CSLB stamps `LastUpdate` on every row: set `updatedSinceDays` to 7 and put the
  actor on a weekly schedule to get a change feed instead of a full re-download.
- The same licence number never appears twice in the export, so `LicenseNo` is
  the dedup key. Dedup is **opt-in** via `incremental`: with it on, a re-run of
  the same query continues further down the export instead of re-billing records
  you already have. With it off (the default) an identical input returns an
  identical result set, because the export is ordered by licence number and a
  reader that remembers past output walks down the file on every run — which
  looks like the dataset silently changing when you ask the same question twice.

# Actor input Schema

## `licenseScope` (type: `string`):

"all" is the complete export — 245 927 licenses. "master" is the shorter list the portal's own UI leads you to (142 422) and it is MISSING 42% of the active licenses, so it is only useful if you specifically want the same slice the CSLB website offers. "personnel" is 155 031 rows. Measured 2026-11-01: master ⊂ personnel ⊂ all.

## `counties` (type: `array`):

California counties to keep, e.g. \["Los Angeles", "Orange"]. Matched case-insensitively against the published county name. 96.6% of records have one. Leave empty for the whole state.

## `classifications` (type: `array`):

License classes by trade, e.g. \["B", "C10", "C36"]. A record matches when ANY of the classes you list is on its license. Common values: B general building 46 605 records, C10 electrical 11 339, C33 painting 7 295, C36 plumbing 7 180, C27 landscaping 5 456, C20 HVAC 4 833, A general engineering 3 136. Leave empty for all trades.

## `activeOnly` (type: `boolean`):

Keep only records whose status is CLEAR — 234 021 of 245 927 when measured. Turn this off to get suspended licenses too, or set the status filter explicitly below.

## `statuses` (type: `array`):

Exact statuses to keep, overriding the switch above. Published values: CLEAR 234 021, Contr Bond Susp, Work Comp Susp, Liab Ins Susp, Family Sup Susp, Susp - No Qualifier, SOS Suspension, EMP/WK Bnd Susp. Leave empty to let the switch decide.

## `businessTypes` (type: `array`):

Published entity types: "Corporation" 70 763, "Sole Owner" 56 934, "Limited Liability" 12 720, "Partnership" 1 614, "JointVenture" 388. Useful for cutting sole proprietors out of a corporate lead list.

## `requiresPhone` (type: `boolean`):

Skip licenses with no business phone. 99.8% of records have one, so this removes about 240 of 245 927 — worth enabling for a pure calling list.

## `licenseNumbers` (type: `array`):

Exact license numbers, e.g. \["334699"]. The export is still streamed and rows are matched as they pass, so this is a filter, not a lookup — for a handful of known licenses it is faster than a county sweep, but it does not avoid the download.

## `updatedSinceDays` (type: `integer`):

Keep licenses whose LastUpdate date falls inside this window. This is the field to use for a recurring "what changed" feed: CSLB stamps it on every row. 0 disables the filter.

## `incremental` (type: `boolean`):

Leave this off to get the same first N matches every time — re-running an identical input is then reproducible, and a repeated test run returns the same licences. Turn it on for a schedule: the actor remembers which licence numbers it already delivered for this exact query and skips them, so each run continues further down the export instead of re-billing records you already have. This is the switch to pair with updatedSinceDays for a change feed.

## `maxResults` (type: `integer`):

Hard cap on the number of records saved to the dataset, and therefore on what you are charged. The download stops as soon as this many matching licenses have been collected, so a small cap also makes the run fast.

## `maxScanRows` (type: `integer`):

Stop reading the export after this many rows even if maxResults is not reached. 0 means no limit. Useful for a cheap sample run against a narrow filter that is rare in the file.

## `includeRaw` (type: `boolean`):

Attach the untouched CSV row to every record under `raw`, with CSLB's original column names (BUS-NAME-2, Classifications(s), NAME-TP-2, ...). Useful for auditing the parse.

## `downloadTimeoutSecs` (type: `integer`):

How long the 78 MB export may take to stream. Measured 2026-11-02: the full 77.7 MB file arrived in 49 seconds. The stream also stops by itself after 240 seconds and returns the records selected so far, so a run never hangs past 5 minutes. Lowering this below the real transfer time fails the run.

## Actor input object example

```json
{
  "licenseScope": "all",
  "activeOnly": true,
  "requiresPhone": false,
  "updatedSinceDays": 0,
  "incremental": false,
  "maxResults": 500,
  "maxScanRows": 0,
  "includeRaw": false,
  "downloadTimeoutSecs": 900
}
```

# Actor output Schema

## `dataset` (type: `string`):

One row per CSLB license, filtered by county, trade classification, status and update date. Every canonical field is always present; values CSLB does not publish are null, never omitted, so the column set is stable between runs. Beyond the shared Company fields each record carries the lead data this source is chosen for: businessPhone and businessPhoneE164, county, classifications, workersComp, bonds, expirationDate and lastUpdate. See the Companies tab for the full table.

## `summary` (type: `string`):

Counters for the run: which export scope was read, rows scanned out of the 245 927 the full file holds, how many rows passed the filters, records selected and pushed, records that failed validation, licenses skipped as already billed, whether the download stopped early because maxResults was reached, download duration and bytes read in seconds, the dataset total, and how many charge events were emitted.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "licenseScope": "all",
    "maxResults": 500
};

// Run the Actor and wait for it to finish
const run = await client.actor("ai-lead-gen-tools/us-ca-cslb").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "licenseScope": "all",
    "maxResults": 500,
}

# Run the Actor and wait for it to finish
run = client.actor("ai-lead-gen-tools/us-ca-cslb").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "licenseScope": "all",
  "maxResults": 500
}' |
apify call ai-lead-gen-tools/us-ca-cslb --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,ai-lead-gen-tools/us-ca-cslb"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/WIKlSR733wTDb6EdG/builds/SDIKec0XdAfhdKvx2/openapi.json
