Arizona ROC Contractor License Scraper
Pricing
Pay per usage
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
Maintained by CommunityActor stats
0
Bookmarked
2
Total users
1
Monthly active users
3 days ago
Last modified
Categories
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.
| Input | Example | Notes |
|---|---|---|
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, usescrapeDetailPage: trueand filter the output dataset'scityfield after the run.
Filters
| Filter | Options | Behavior |
|---|---|---|
licenseType | ALL / RESIDENTIAL / COMMERCIAL / DUAL | Applied post-scrape to collected records |
licenseStatus | ALL / ACTIVE / SUSPENDED / EXPIRED / REVOKED / CANCELLED | Applied post-scrape — does not narrow the portal search itself |
licenseClassification | Any code, e.g. B-1, CR-42, C-37 | Applied post-scrape. See common codes below. |
Note on filters:
licenseStatusis enforced post-scrape — records collected from the portal are filtered before being pushed to your dataset.licenseClassificationfilters 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
| Code | Trade | Type |
|---|---|---|
B-1 | General Commercial Contractor | Commercial |
A | General Dual Contractor (residential + commercial) | Dual |
CR-11 / C-11 | Electrical | Residential / Commercial |
CR-37 / C-37 | Plumbing | Residential / Commercial |
CR-39 / C-39 | Air Conditioning & Refrigeration (HVAC) | Residential / Commercial |
CR-42 / C-42 | Roofing | Residential / Commercial |
CR-10 / C-10 | Drywall | Residential / Commercial |
CR-8 / C-8 | Floor Covering | Residential / Commercial |
CR-34 / C-34 | Painting and Wall Covering | Residential / Commercial |
CR-61 / C-61 | Carpentry, Remodeling and Repairs | Residential / Commercial |
CR-16 / C-16 | Fire Protection Systems | Residential / Commercial |
CR-6 / C-6 | Swimming Pool Service and Repair | Residential / Commercial |
CR-60 / C-60 | Finish Carpentry | Residential / Commercial |
Common mistake:
CR-39is Air Conditioning, not Roofing. Roofing isCR-42(residential) orC-42(commercial). TheCR-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. UselicenseIdto 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:
| Field | Description |
|---|---|
entityType | Corporation, LLC, Sole Proprietor, etc. |
issuedDate | YYYY-MM-DD — when the license was originally issued |
renewedThroughDate | YYYY-MM-DD — current license expiration |
classifications | Array of all classification codes + descriptions for this business |
bondType | Type of surety bond |
bondStatus | ACTIVE or INACTIVE |
bondAmount | Dollar amount (number) |
bondCompany | Surety bond company name |
bondNumber | Bond certificate number |
bondEffectiveDate | YYYY-MM-DD |
bondExpirationDate | YYYY-MM-DD |
openCases | Currently open complaint cases |
disciplinedCases | Disciplined complaint cases |
resolvedCases | Resolved / settled cases |
complaintCount | Total of all complaint types |
complaints | Array of individual complaint records (last 2 years only) |
New in v1.1
licenseType—RESIDENTIAL,COMMERCIAL, orDUAL, 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 numberspersonnel.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
| Scenario | Approx time per result | Notes |
|---|---|---|
| License number lookups | ~35–45s per number | Includes proxy rotation; use maxConcurrency: 3 to parallelize |
| Company / QP search | ~30–60s per search | Small result sets are faster |
| City search (small, < 50 results) | ~60–120s | One page |
| City search (large, 200+ results) | ~5–10 min | Multiple paginated pages |
Cost tip:
scrapeDetailPage: falseis ~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
cityfield 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 ≤ 5is 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:
licenseClassificationfilter 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 asDUALinstead ofCOMMERCIAL - Fix:
personnel.namevalues now normalized (Title Case, acronyms preserved) — matchesqualifyingPartycasing - New:
classificationPrefixfield on every record (raw prefix likeCR,C,B,KB) for users who want to audit thelicenseTypeinference - New:
searchSucceededfield onfound: falserecords —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;
searchSucceededexplained in not-found schema
v1.2.0
- Fix:
scrapeDetailPage: trueno longer overwrites good list-level fields (businessName, licenseStatus, phone, personnel) with null detail-page values — only non-null detail fields are merged - Fix:
businessNamenow split correctly when portal concatenates DBA — "Acme LLC DBA : Trade Name" →businessName: "Acme LLC",dbaName: "Trade Name" - Fix:
licenseStatusfilter now enforced post-scrape — Expired/Inactive records are removed from output when you request ACTIVE only qualifyingPartyand all-capsbusinessNamevalues now normalized to Title CasedbaNamenow 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.jsonversion bumped to 1.1
v1.1.0
licenseTypefield inferred from classification prefix (RESIDENTIAL / COMMERCIAL / DUAL)searchTypeandsearchQueryfields on every record for batch traceabilityfound: falserecord pushed for license numbers not in the AZ ROC databasepersonnel.rolesis now an array (was semicolon-joined string)- City names normalized to Title Case (
"PHOENIX"→"Phoenix") - Phone numbers normalized to
(NXX) NXX-XXXXformat maxResultsPerSearchdefault 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