Arizona ROC Contractor License Scraper avatar

Arizona ROC Contractor License Scraper

Pricing

Pay per usage

Go to Apify Store
Arizona ROC Contractor License Scraper

Arizona ROC Contractor License Scraper

Scrape the Arizona Registrar of Contractors (AZ ROC) public license database. Search by license number, company name, qualifying party, or city. Returns license status, bond details, complaint history, and full contractor info. $4.00 per 1,000 results.

Pricing

Pay per usage

Rating

0.0

(0)

Developer

Tony

Tony

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

3 days ago

Last modified

Share

🏗️ Arizona ROC Contractor License Scraper

Scrape the Arizona Registrar of Contractors (AZ ROC) public license database with full pagination support. Search by license number, company name, qualifying party, or city. Returns complete license details — bond status, complaint history, personnel, and classification codes.

Pricing: $4.00 / 1,000 results — pay only for what you collect.


Who uses this

  • General contractors — verify subcontractor license status and bond coverage before signing a contract
  • Lead generation — build targeted lists of active contractors by city or trade classification
  • Compliance teams — bulk-check license numbers against an internal vendor list
  • Homeowners & realtors — confirm a contractor is properly licensed before paying a deposit
  • Researchers — analyze complaint patterns, bond requirements, or market density by city

Search modes

Provide any combination of the inputs below. Each entry runs as an independent search job.

InputExampleNotes
licenseNumbers["333282", "12345"]Fastest — direct lookup by AZ ROC number. Produces a found: false record for any number not in the database.
companyNames["Desert HVAC", "Acme Plumbing"]Partial / fuzzy match — "Arizona Painting" returns all companies with those words in the name
qualifyingPartyNames["John Smith"]Searches by individual qualifying party name
cities["Scottsdale", "Mesa"]See ⚠️ note below

⚠️ How the AZ ROC portal searches: The portal uses a single text-search box — it does not have separate fields for city, company name, or QP. When you search cities: ["Tempe"], the portal finds all records containing the word "Tempe" anywhere, including company names like "Temperature Control Plumbing." This is Salesforce platform behavior, not a bug. For precise city filtering, use scrapeDetailPage: true and filter the output dataset's city field after the run.

Filters

FilterOptionsBehavior
licenseTypeALL / RESIDENTIAL / COMMERCIAL / DUALApplied post-scrape to collected records
licenseStatusALL / ACTIVE / SUSPENDED / EXPIRED / REVOKED / CANCELLEDApplied post-scrape — does not narrow the portal search itself
licenseClassificationAny code, e.g. B-1, CR-42, C-37Applied post-scrape. See common codes below.

Note on filters: licenseStatus is enforced post-scrape — records collected from the portal are filtered before being pushed to your dataset. licenseClassification filters the output to only records matching that code. Neither filter narrows the portal's server-side text search, but both are applied to what reaches your dataset.

Common classification codes

CodeTradeType
B-1General Commercial ContractorCommercial
AGeneral Dual Contractor (residential + commercial)Dual
CR-11 / C-11ElectricalResidential / Commercial
CR-37 / C-37PlumbingResidential / Commercial
CR-39 / C-39Air Conditioning & Refrigeration (HVAC)Residential / Commercial
CR-42 / C-42RoofingResidential / Commercial
CR-10 / C-10DrywallResidential / Commercial
CR-8 / C-8Floor CoveringResidential / Commercial
CR-34 / C-34Painting and Wall CoveringResidential / Commercial
CR-61 / C-61Carpentry, Remodeling and RepairsResidential / Commercial
CR-16 / C-16Fire Protection SystemsResidential / Commercial
CR-6 / C-6Swimming Pool Service and RepairResidential / Commercial
CR-60 / C-60Finish CarpentryResidential / Commercial

Common mistake: CR-39 is Air Conditioning, not Roofing. Roofing is CR-42 (residential) or C-42 (commercial). The CR- prefix always means Residential specialty; C- means Commercial specialty.


Quick-start examples

Batch license number lookup (fastest, cheapest)

{
"licenseNumbers": ["333282", "333239", "333681"],
"scrapeDetailPage": false
}

Returns immediately with list-level data. Missing license numbers appear as { "found": false } records so you always know which ones weren't found.

Verify one contractor before hiring

{
"licenseNumbers": ["333282"],
"scrapeDetailPage": true,
"scrapeComplaints": true
}

Returns the full record including bond details and any complaint history from the last 2 years.

Active residential contractors in Scottsdale (lead gen)

{
"cities": ["Scottsdale"],
"licenseStatus": "ACTIVE",
"maxResultsPerSearch": 200,
"scrapeDetailPage": false
}

Set scrapeDetailPage: false first to preview the data cheaply. Enable it for your final run once you've confirmed the results look right.

Commercial drywall contractors (by classification)

{
"cities": ["Phoenix", "Tucson"],
"licenseType": "COMMERCIAL",
"licenseClassification": "CR-10",
"licenseStatus": "ACTIVE",
"maxResultsPerSearch": 500
}

Output schema

Each record is a flat JSON object pushed to the default Apify Dataset.

One record per license, not per company. A contractor holding 3 classification codes (e.g. CR-8, CR-11, CR-42) will appear as 3 separate records, each with a different primaryClassification. This is how AZ ROC issues licenses — one per trade. Use licenseId to group records belonging to the same business if needed.

List-only record (scrapeDetailPage: false)

{
"licenseId": "a0o8y0000004CQAAA2",
"licenseNumber": "333282",
"businessName": "Grey Wolf Drywall LLC",
"dbaName": null,
"licenseType": "RESIDENTIAL",
"classificationPrefix": "CR",
"primaryClassification": "CR-10",
"classificationDesc": "Drywall",
"qualifyingParty": "Alfonso Lopez",
"personnel": [
{ "name": "Alfonso Lopez", "roles": ["Member"] },
{ "name": "Alfonso Lopez", "roles": ["Qualifying Party"] }
],
"licenseStatus": "Active",
"city": "Waddell",
"state": "AZ",
"zip": "85355",
"phone": "(602) 317-1895",
"profileUrl": "https://azroc.my.site.com/AZRoc/s/contractor-search?licenseId=a0o8y0000004CQAAA2",
"searchType": "LICENSE_NUMBER",
"searchQuery": "333282",
"scrapedAt": "2026-05-30T03:34:36.125Z"
}

Not-found record (license number not in database)

{
"licenseNumber": "999999",
"found": false,
"searchSucceeded": true,
"searchType": "LICENSE_NUMBER",
"searchQuery": "999999",
"scrapedAt": "2026-05-30T03:34:36.125Z"
}

searchSucceeded: true means the portal confirmed 0 results — the license number is likely invalid or doesn't exist. searchSucceeded: false means the portal failed to load for this request (proxy issue, rate limiting). If you see searchSucceeded: false on a license you know exists, re-run that number before treating it as invalid.

Full record (scrapeDetailPage: true)

Adds the following fields:

FieldDescription
entityTypeCorporation, LLC, Sole Proprietor, etc.
issuedDateYYYY-MM-DD — when the license was originally issued
renewedThroughDateYYYY-MM-DD — current license expiration
classificationsArray of all classification codes + descriptions for this business
bondTypeType of surety bond
bondStatusACTIVE or INACTIVE
bondAmountDollar amount (number)
bondCompanySurety bond company name
bondNumberBond certificate number
bondEffectiveDateYYYY-MM-DD
bondExpirationDateYYYY-MM-DD
openCasesCurrently open complaint cases
disciplinedCasesDisciplined complaint cases
resolvedCasesResolved / settled cases
complaintCountTotal of all complaint types
complaintsArray of individual complaint records (last 2 years only)

New in v1.1

  • licenseTypeRESIDENTIAL, COMMERCIAL, or DUAL, inferred from the classification code prefix (CR- = Residential, C- = Commercial, B- = Commercial, A- = Dual, etc.)
  • searchType / searchQuery — which input produced this record (useful in batch runs)
  • found: false — explicit not-found record for unmatched license numbers
  • personnel.roles — array of role strings per person (was a semicolon-joined string)
  • City normalization"PHOENIX" and "phoenix" both become "Phoenix"
  • Phone normalization — all formats unified to (602) 317-1895

Performance

ScenarioApprox time per resultNotes
License number lookups~35–45s per numberIncludes proxy rotation; use maxConcurrency: 3 to parallelize
Company / QP search~30–60s per searchSmall result sets are faster
City search (small, < 50 results)~60–120sOne page
City search (large, 200+ results)~5–10 minMultiple paginated pages

Cost tip: scrapeDetailPage: false is ~2–3× faster and cheaper. Use it for lead-gen list-building; enable it only when you need bond and complaint data.


Proxy recommendation

The AZ ROC portal is hosted on Salesforce with standard bot protection. Residential proxies are strongly recommended for production runs. Datacenter IPs may work for small test runs but are not reliable at scale.

The default proxy config (RESIDENTIAL group) is the safest choice.


Limitations

  • Complaint history shows only the prior two years from the portal.
  • No official API — all data is parsed from the rendered Salesforce LWC DOM. Major Salesforce updates may require selector adjustments.
  • City search is text-based — searches for "Tempe" will match any record containing that string, including company names. Filter the output city field for precise location filtering.
  • Status / classification filters are post-scrape — they do not narrow the portal's server-side search, only the records returned to you.
  • Rate limiting — Salesforce may throttle aggressive crawls. maxConcurrency ≤ 5 is recommended.
  • License not found — if a license number returns no results, a { found: false } record is pushed so you always have a complete accounting of your input list.

FAQ

Q: I searched for 5 license numbers and only got 3 records — what happened to the other 2? Those 2 license numbers were not found in the AZ ROC database. They will appear in your dataset as { "licenseNumber": "...", "found": false } records. This could mean the number is invalid, the license was purged, or you have a typo. Check the AZ ROC portal directly at azroc.my.site.com to confirm.

Q: I searched for cities: ["Tempe"] and got contractors from Tucson — is it broken? No — the AZ ROC portal uses a single text-search field. Searching for "Tempe" matches any record containing that word, including company names like "Temperature Control." For a true city filter, run a broader search and then filter the output dataset's city field to "Tempe" after the fact.

Q: My status filter (licenseStatus: "ACTIVE") isn't narrowing results — why? The filter is applied post-scrape: the actor collects all results the portal returns for your search, then filters by status before pushing to the dataset. If you're seeing expired records, it may be that the portal returned them before the filter was applied. Double-check your input and re-run with a smaller maxResultsPerSearch to inspect the raw results.

Q: How much does a full city scrape cost? With scrapeDetailPage: false, a city returning 500 records costs $2.00 (500 × $4.00/1000). With scrapeDetailPage: true, add roughly 500 more page loads, bringing it to ~$4.00 total. Always set maxResultsPerSearch on your first run to avoid surprises.

Q: What classification code should I use for plumbers / electricians / roofers? See the classification table above. Quick reference: Plumbing = CR-37 / C-37, Electrical = CR-11 / C-11, Roofing = CR-42 / C-42, HVAC = CR-39 / C-39. Note: CR- is residential, C- is commercial — run both if you need all license types for a trade.

Q: Why do some records show a Qualifying Party that doesn't match my search? When you search by QP name, the AZ ROC portal matches against all personnel on a license, not just the Qualifying Party field. A license where "Garcia" is listed as an Owner or Member will appear in results for qualifyingPartyNames: ["Garcia"], even if the QP is someone else. The qualifyingParty field in the output shows who the actual QP is — filter your dataset on that field to narrow results.

Q: My run timed out before finishing — what do I do? The actor's default timeout is 3600 seconds (1 hour), but this can be overridden by your Apify plan's limits. For large jobs (many license numbers with detail pages, or broad city searches), go to the Run panel → Options → Timeout and set it explicitly to 3600s or higher. Detail-page scraping takes ~60–90s per record — plan your run size accordingly.


Technical architecture

Actor Input
main.js — builds one job per search term
PlaywrightCrawler (Crawlee + fingerprint generator)
├─► SEARCH requests (routes.js)
│ ├─ Navigate to AZ ROC portal (waitUntil: domcontentloaded)
│ ├─ Wait for Search button to appear (up to 45s for LWC to render)
│ ├─ Fill search field (getByLabel strategy + placeholder fallback)
│ ├─ Submit → wait for results table
│ ├─ Set page size to 50 (dispatch native change event on <select>)
│ │
│ └─► Pagination loop
│ ├─ Parse table HTML → parser.js
│ │ ├─ Rowspan + separator-row handling
│ │ ├─ licenseType inference from classification prefix
│ │ ├─ City toTitleCase normalization
│ │ └─ Phone format normalization
│ ├─ Stamp searchType + searchQuery on each record
│ ├─ Push records or enqueue DETAIL requests
│ ├─ If LICENSE_NUMBER and 0 results → push found:false record
│ ├─ Locate Next button (button.slds-button.right-btn)
│ └─ Repeat until disabled or maxResultsPerSearch reached
└─► DETAIL requests (routes.js)
├─ Navigate to contractor profile URL
├─ Parse detail page → parser.js (data-label, dt/dd, SLDS form elements)
└─ Actor.pushData() — merged base + detail record

Changelog

v1.3.0 (current)

  • Fix: licenseClassification filter now enforced post-scrape — only records matching the requested code are pushed (was previously ignored)
  • Fix: KB* licenses (KB-1, KB-2) now correctly typed as DUAL instead of COMMERCIAL
  • Fix: personnel.name values now normalized (Title Case, acronyms preserved) — matches qualifyingParty casing
  • New: classificationPrefix field on every record (raw prefix like CR, C, B, KB) for users who want to audit the licenseType inference
  • New: searchSucceeded field on found: false records — true = portal confirmed 0 results (likely invalid license), false = portal failed to load (transient network error, re-run before concluding)
  • README: one-record-per-license callout; searchSucceeded explained in not-found schema

v1.2.0

  • Fix: scrapeDetailPage: true no longer overwrites good list-level fields (businessName, licenseStatus, phone, personnel) with null detail-page values — only non-null detail fields are merged
  • Fix: businessName now split correctly when portal concatenates DBA — "Acme LLC DBA : Trade Name" → businessName: "Acme LLC", dbaName: "Trade Name"
  • Fix: licenseStatus filter now enforced post-scrape — Expired/Inactive records are removed from output when you request ACTIVE only
  • qualifyingParty and all-caps businessName values now normalized to Title Case
  • dbaName now populated at list level (was only available from detail page)
  • Classification code table added to README with common trade codes
  • FAQ expanded: QP mismatch explanation, timeout guidance, corrected Roofing code (CR-42, not CR-39)
  • actor.json version bumped to 1.1

v1.1.0

  • licenseType field inferred from classification prefix (RESIDENTIAL / COMMERCIAL / DUAL)
  • searchType and searchQuery fields on every record for batch traceability
  • found: false record pushed for license numbers not in the AZ ROC database
  • personnel.roles is now an array (was semicolon-joined string)
  • City names normalized to Title Case ("PHOENIX""Phoenix")
  • Phone numbers normalized to (NXX) NXX-XXXX format
  • maxResultsPerSearch default changed from 0 (unlimited) to 100
  • README: corrected performance estimates, added city search warning, added filter behavior notes, added FAQ

v1.0.0

  • Initial release: license number, company name, qualifying party, and city search
  • Full pagination with class-based Next button detection
  • Rowspan-aware results table parser
  • Residential proxy defaults