How-to
How to run US Contractor License Lookup & List Scraper (State Boards)
Step-by-step: verify contractor licences or export statewide contractor lists across many US states in one run - the exact input, what each field does, and the rows that come back.
You are building something — a vendor-onboarding flow, a subcontractor check, a CRM intake step, an agent — that needs to answer one question: is this contractor actually licensed in this state? Or you need the list itself: every licensed electrician in two states. Every state board has its own portal: California’s CSLB is an ASP.NET WebForms portal, South Carolina’s board puts a reCAPTCHA on the search itself, Virginia publishes a bulk roster with no search at all. This actor is one call in for many states: a flat verdict per contractor per state, or each state’s roster, with the board that answered cited on the row.
What you’ll need
Nothing but an Apify account. Each state is answered by that state’s own scrapebench actor, which
this one starts for you under your account; the coverage list is in the states field below.
Nothing else. No API key, no proxy configuration, no CAPTCHA-solving account. Two boards CAPTCHA their search. South Carolina’s is solved inside its per-state lookup actor on every check, and that cost is already in the price — it is why South Carolina costs more per check. New Mexico’s lookup actor solves nothing at run time: it reads a monthly snapshot of the whole NM CID registry, so a New Mexico verdict reflects the latest monthly sweep rather than the board’s live answer.
Step 1 — Open the actor
Open US Contractor License Lookup & List Scraper (State Boards) on the Apify Store and press Try for free. The actor’s own page on this site lists its live health and pricing.
Step 2 — Fill in the input
| Field | Type | What it does |
|---|---|---|
mode | string | verify (default): names or licence numbers in, one verdict per contractor per state. export: each state’s roster, or a filtered slice of it. |
states | array | 2-letter codes. Verify covers AL, AR, AZ, CA, CO, CT, DC, DE, FL, IA, ID, MA, MD, MI, MN, MS, NC, ND, NE, NJ, NM, NV, NY, OH, OR, PA, RI, SC, TN, TX, VA, WA, WV; export covers AL, AR, AZ, CA, CO, CT, DC, DE, FL, IA, ID, IL, LA, MD, MN, MS, ND, NE, NJ, NM, NV, NY, OH, OR, PA, RI, SC, TN, TX, UT, VA, WA, WV. state takes a single code. |
companies / company | array / string | Verify: business or licensee names. |
licenseNumbers / licenseNumber | array / string | Verify: licence numbers — the most reliable input. |
stateFilters | object | Export: per state, that state actor’s own input fields, passed through unchanged, e.g. {"TX": {"county": "Travis"}}. |
maxResults | integer | Export: the most rows per state (default 500). |
states is the router, not a filter. Contractor licensing is per-state, so every name and
number is checked in every state you list. If your user has not said which state, ask them rather
than guessing; guessing produces a confident answer about the wrong registry.
Send licenseNumber whenever you have one. It is exact. company is matched on the whole
normalized business name (case, punctuation and LLC/Inc suffixes ignored), so “ABC Roofing” will
not match “ABC Roofing & Siding” — deliberately. If a name matches two licences you get
check_failed rather than a coin flip, because handing back the wrong contractor’s licence is
worse than handing back nothing.
One exception worth knowing: Delaware, Iowa, Mississippi, Ohio and Virginia cannot be searched
by licence number at all. Their boards take names only, so a number-only request there returns
check_failed telling you to supply the name.
A working verify input:
{
"mode": "verify",
"states": ["CA"],
"licenseNumbers": ["22726"]
}
And an export of electrical contractors in Travis County, Texas:
{
"mode": "export",
"states": ["TX"],
"stateFilters": {"TX": {"licenseType": "Electrical Contractor", "county": "Travis"}},
"maxResults": 500
}
Export mode opens on 2026-11-14, when its $0.001-per-row price takes effect; until then it returns a free notice.
Step 3 — Run it
Press Start. Each state runs as its own actor run (at most three at once), and its rows are pushed as that state finishes.
In verify mode, one request in, one row out per state — always. Every outcome, including “we could not check”, is a row with a verdict.
Measured on 2026-08-06, one licence-number lookup per state: median 13.3 seconds, fastest 10.6s (Florida, California, Washington, Michigan, North Carolina), slowest 30.8s for South Carolina, whose board CAPTCHAs the search. Those are single-state timings; each extra state adds its own cold start. If you are calling this from an agent loop, set your timeout above 35 seconds for one state.
Step 4 — Read the output
Each run returns a labelled table, not raw JSON:
A verify row looks like this — a real row from a real run:
{
"verdict": "active",
"verdict_reason": "status 'This license is current and active.', expires 2027-10-31",
"verdict_basis": "status+expiry_date",
"requested_company": null,
"requested_license_number": "22726",
"requested_state": "CA",
"entity_name": "RANSOME COMPANY",
"license_number": "22726",
"license_type": null,
"status_raw": "This license is current and active.",
"expiry_date": "10/31/2027",
"days_to_expiry": 451,
"source_portal": "CSLB",
"source_url": "https://www.cslb.ca.gov/OnlineServices/CheckLicenseII/LicenseDetail.aspx?LicNum=22726"
}
| Field | Example value | Meaning |
|---|---|---|
verdict | active | active |
verdict_reason | status 'This license is current and active.', expires 2027-10-31 | Plain-language explanation, safe to quote to an end user |
verdict_basis | status+expiry_date | Which evidence the verdict rests on: status, expiry_date, status+expiry_date, or none |
requested_company | — | The company name that was searched, echoed back |
requested_license_number | 22726 | The license number that was searched, echoed back |
requested_state | CA | The state that was searched |
entity_name | RANSOME COMPANY | The licensed business or person as the board publishes it |
license_number | 22726 | License number as published by the board |
verdict is the field, and the discipline is in what it refuses to say.
not_found does not mean unlicensed. It means the registry returned no match for what you
searched. A typo, a DBA that differs from the licensed entity, a licence held under a parent
company — all produce not_found. If your agent turns that into “this contractor is unlicensed”
in front of an end user, it has made a false and damaging claim about a real business. Branch it
to “could not verify, check manually”.
inactive is not revoked. The boards publish “Lapsed”, “Voluntary Surrender”, “Archived”,
“Voluntary Termination” — all real strings, none of them a disciplinary action. Those map to
inactive. revoked and suspended are reserved for what the board actually calls a revocation
or suspension. Do not paraphrase one as the other.
check_failed is never a pass. Board down, ambiguous name, a record missing a field that
board normally publishes — all fail closed.
verdict_basis tells you what the answer rests on, because the boards differ: Alabama, Delaware,
Texas, Virginia and West Virginia do not reliably publish a status, so those verdicts can stand on
the expiry date alone; North and South Carolina publish no expiration date, and Arizona,
Mississippi and New Jersey often omit it, so those can stand on status alone. Elsewhere you get
status+expiry_date. status_raw always carries the board’s own wording if you want
to quote it verbatim.
An export row is the state actor’s own row, unchanged, plus source_state — so the fields differ
from state to state, exactly as if you had run that state’s actor yourself.
Step 5 — Export it
Open the Dataset tab and export to CSV, JSON, or Excel — or pull the same rows from the API, which is what you want if this is going to run on a schedule.
What it costs
$0.001 per row from 2026-11-14 — per verdict resolved on a board, or per contractor row
delivered in an export ($0.004 per verdict until then). Verdicts of not_found,
unverifiable_state_not_covered and check_failed, and every notice row, are free — they describe
the check, not the contractor.
There is a second line: each state runs as its own actor under your account and bills its own per-row fee there — most states $0.002–$0.004 a row, plus a per-search fee on CAPTCHA boards such as South Carolina. In verify mode the state actor bills each candidate it returns (one for a licence number, up to 20 for a common name), which is another reason to send numbers. So a verified California licence is about $0.005 in total, and so is an exported Texas row.
Where the data comes from
Each state is read by its own scrapebench actor — open-data endpoints, ASP.NET WebForms portals, CAPTCHA-gated searches, bulk rosters and monthly snapshots — and this actor routes to it. Every night a canary runs this actor against that live source and diffs the result against a frozen fixture — what “verified” means.
No two of these boards agree on anything. Some are open-data endpoints answering in under a second; CSLB is a WebForms portal behind a WAF that will hand you an ordinary-looking 200 that is really a redirect; South Carolina and New Mexico gate the search itself behind a CAPTCHA. They disagree on date formats, on whether a status field exists at all, on whether an expiry date exists, and on what their own status words mean.
That last one is the real work. On 2026-08-06 Florida’s DBPR went into scheduled maintenance and
served a perfectly normal page saying so — which parsed to zero results and would have reported
every Florida contractor as not_found. That is now caught and returned as check_failed. The
value here is not fetching pages; it is refusing to turn a broken check into a confident wrong
answer.
See it used
- Give an AI agent the ability to verify a contractor licence — wiring this into a tool-calling loop, and the three verdicts your prompt must handle before you ship it.
Watching a whole roster rather than answering one question? Use Contractor License Expiration Monitor instead — same boards, but it re-checks a watchlist on a schedule and alerts you on changes.
Last updated 2026-10-29