DEV Community

Loomwise
Loomwise

Posted on

Turning Contractor Registrations and Building Permits into a Sales Pipeline

By Loomwise

If you sell to contractors (materials, equipment rental, insurance and bonding, payroll, software), the best lead is a business that has just started a new kind of work. Two public datasets show exactly that, and both are free to query:

  1. New contractor registrations. In New York State, contractors and subcontractors must register with the NYS Department of Labor's Bureau of Public Work and Prevailing Wage Enforcement before bidding on public work or starting a private project covered by prevailing wage. A new certificate means a business is getting ready for that kind of work. The registry is published as open data at data.ny.gov (dataset i4jv-zkey) and refreshed daily.
  2. Newly issued building permits. A permit means a project has been approved and work is about to start at a specific address. Many large cities publish permits on Socrata or CKAN open-data portals, and some include the contractor's business name.

This tutorial turns both into one daily, de-duplicated lead list. You'll filter each source, join them on business name, schedule the run, and send the rows to your CRM or a sheet. You'll also see where the data has gaps.

Step 0: Write down who you're looking for

Settle three things before you touch any data:

  • Trade: electrical, roofing, solar, HVAC, paving and so on.
  • Territory: ZIP prefixes or cities.
  • Disqualifiers: do you want to skip businesses with a debarment or outstanding wage assessments, or are those exactly the ones you sell compliance services to?

Step 1: New registrations as a feed

The NY registry has a lot of fields. For prospecting, the useful ones are the certificate dates, location, and the compliance flags. The examples below use the input format of a ready-made Apify Actor for this registry (linked at the end). You could also query data.ny.gov's API directly.

Input: new electrical contractors in Western New York (ZIP codes starting with 14) over the last 90 days, active certificates only, debarred businesses skipped:

{
  "issuedWithinDays": 90,
  "keywords": ["electric"],
  "zipCodes": ["14"],
  "activeOnly": true,
  "excludeDebarred": true,
  "cleanRecordOnly": false,
  "includeContactDetails": true,
  "maxResults": 500
}
Enter fullscreen mode Exit fullscreen mode

What the filters do:

  • issuedWithinDays is the certificate issue date window (0 = no limit). For a fixed range, use issuedFrom/issuedTo (YYYY-MM-DD) instead.
  • keywords matches words in the legal or DBA business name only, not the trade a business registered for. "Electric" finds Example Electric LLC but not Example Holdings LLC doing electrical work. Use several synonyms.
  • cities is an exact match (case-insensitive). zipCodes matches prefixes (112 = Brooklyn, 14 = Western NY).
  • businessTypes can be Corporation, LLC, LLP, Partnership, Association or Government.
  • excludeDebarred (default true) skips businesses that report a state or federal debarment. cleanRecordOnly goes further and keeps only businesses with no outstanding wage assessments, debarments, labor/tax-law determinations or safety violations.
  • mwbeOnly keeps only businesses that report being minority- or women-owned. This is useful for primes that need MWBE subs.

One output row. The field names are exact, but the values here are made up:

{
  "certificateNumber": "26-XXXXX-CR",
  "businessName": "EXAMPLE ELECTRIC LLC",
  "businessType": "LLC",
  "status": "Active",
  "issuedDate": "2026-10-16",
  "expirationDate": "2028-12-29",
  "businessAddress": "123 EXAMPLE STREET",
  "city": "Buffalo",
  "state": "NY",
  "zipCode": "14201",
  "businessPhone": "(555) 555-0100",
  "mwbeOwned": false,
  "hasWorkersCompInsurance": true,
  "hasOutstandingWageAssessments": false,
  "debarred": false,
  "hasSafetyStandardViolation": false,
  "apprenticeshipAssociated": true,
  "latitude": 42.89,
  "longitude": -78.87,
  "sourceUrl": "https://data.ny.gov/d/i4jv-zkey"
}
Enter fullscreen mode Exit fullscreen mode

The full record also includes dbaName, hasLaborOrTaxLawViolation, workersCompExempt, debarment details (debarmentLaw, debarmentStartDate, debarmentEndDate) and apprenticeship fields. There are two ready-made dataset views: New contractors and Compliance flags.

Ways the flags turn into pitches:

  • hasWorkersCompInsurance: false or workersCompExempt: true: a lead for insurance and bonding.
  • apprenticeshipAssociated: false: a lead for apprenticeship programs and training providers.
  • hasOutstandingWageAssessments: true: a lead for certified-payroll and compliance tools. For primes vetting subs, it's a reason to ask questions first.

Privacy behavior, and why some businesses are missing on purpose. The feed is business-only. Officer names are never requested from the source. Sole proprietorships are always excluded. A row is kept only if the name carries a legal-entity designator (LLC, Inc., Corp., Co., Company, Ltd., LP, LLP, PC, PLLC, DPC, Joint Venture/JV). DBA names are kept only when they carry one too. The address and phone are the registered business address and phone, and includeContactDetails: false removes them. Each run's RUN_SUMMARY record shows how many non-entity rows were dropped. Keep in mind that the registry covers businesses registered for public-work and prevailing-wage projects. It is not a list of every contractor in New York, and it doesn't include NYC home-improvement licenses or local municipal licenses.

Step 2: New permits as a feed

City permit data differs a lot from city to city: field names, refresh schedules, and whether contractors are named at all. A unified schema saves you from writing nine parsers. Here's what the nine cities in the Actor below publish:

City Refresh Contractor business name
Chicago, IL daily yes
New York City, NY (DOB NOW) daily yes (permittee business)
Los Angeles, CA weekly not published
San Francisco, CA daily not published
Seattle, WA daily sometimes
Austin, TX daily yes
Boston, MA daily rarely (applicant companies only)
Cincinnati, OH daily yes
Baton Rouge, LA daily yes

Input: roofing and solar permits from the last 2 days in cities that name contractors, only permits that list a contractor business:

{
  "cities": ["chicago", "nyc", "austin", "cincinnati", "baton_rouge"],
  "issuedWithinDays": 2,
  "workCategories": ["Roofing", "Solar"],
  "onlyWithContractor": true,
  "maxResults": 500,
  "maxResultsPerCity": 150
}
Enter fullscreen mode Exit fullscreen mode
  • workCategories is a normalized category (New construction, Addition / alteration, Demolition, Roofing, Solar, Electrical, Plumbing, Mechanical / HVAC, Fire protection, Sign, Scaffold / shed / fence, Structural / foundation / earthwork, Other). Categories are assigned by fixed keyword rules from each city's permit type and work class, with the description as a fallback. To filter on a city's own wording, use permitTypes (e.g. "Sidewalk Shed", "Commercial").
  • keywords matches the description, type or work class, e.g. restaurant, ADU, pool, battery.
  • minValuation keeps permits at or above a declared job value. Permits with no valuation are excluded when this is set, and NYC trade permits often have none.
  • maxResults is a total cap across cities, newest first. maxResultsPerCity stops one busy city from filling the whole list.

One output row. The field names are exact, but the values here are made up:

{
  "city": "Austin",
  "state": "TX",
  "permitNumber": "2026-XXXXXX RP",
  "permitType": "Building Permit - Residential",
  "workClass": "Repair",
  "workCategory": "Roofing",
  "description": "Reroof, tear off and replace shingles ...",
  "status": "Active",
  "appliedDate": "2026-10-10",
  "issuedDate": "2026-10-17",
  "valuation": null,
  "address": "100 EXAMPLE DR",
  "zipCode": "78701",
  "latitude": 30.27,
  "longitude": -97.74,
  "housingUnitsChange": null,
  "squareFeet": null,
  "contractorBusinessName": "Example Roofing LLC",
  "contractorRole": "General Contractor",
  "contractors": [{ "name": "Example Roofing LLC", "role": "General Contractor" }],
  "permitUrl": "https://...",
  "sourceUrl": "https://data.austintexas.gov/d/3syk-w9eu"
}
Enter fullscreen mode Exit fullscreen mode

Cities publish different fields, and missing values are null, not guesses. The Contractor leads dataset view gives one line per contractor-permit pair. RUN_SUMMARY gives per-city counts and lists any city that was temporarily unavailable. One city being down doesn't fail the run.

Privacy behavior. Owner, applicant and individual-contractor name columns are never requested from the city APIs. Where a city mixes people and companies in one column (Chicago contacts, Boston applicants, Cincinnati company), a name is kept only if it has an entity designator. A string like ACME ELECTRIC CO / JOHN DOE is split and only the company part is kept. Emails and phone numbers in descriptions are masked. The address is the project site, never an owner's mailing address.

Step 3: Join the two on business name (NYC example)

NYC permits name the permittee business, and the NY registry lists businesses registered for public work. If you match the two, you get businesses with active private projects that are also set up for public work, which is a strong signal for suppliers. Names are never typed the same way twice, so normalize them before matching:

import re, json

DESIGNATORS = r"\b(LLC|L L C|INC|INCORPORATED|CORP|CORPORATION|CO|COMPANY|LTD|LP|LLP|PC|PLLC|DPC|JV|JOINT VENTURE)\b"

def norm(name: str | None) -> str:
    if not name:
        return ""
    s = name.upper().replace("&", " AND ")
    s = re.sub(r"[^\w\s]", " ", s)      # drop punctuation
    s = re.sub(DESIGNATORS, " ", s)      # drop LLC, INC, ...
    return re.sub(r"\s+", " ", s).strip()

registry = json.load(open("ny_registry.json"))  # exported dataset (JSON)
permits = json.load(open("nyc_permits.json"))

by_name = {norm(r["businessName"]): r for r in registry}
for r in registry:                              # also match on DBA names
    if r.get("dbaName"):
        by_name.setdefault(norm(r["dbaName"]), r)

matches = []
for p in permits:
    reg = by_name.get(norm(p.get("contractorBusinessName")))
    if reg:
        matches.append({
            "business": reg["businessName"],
            "certificateNumber": reg["certificateNumber"],
            "registeredOn": reg["issuedDate"],
            "permitNumber": p["permitNumber"],
            "workCategory": p["workCategory"],
            "projectAddress": p["address"],
            "permitIssued": p["issuedDate"],
            "permitUrl": p["permitUrl"],
        })

print(len(matches), "matched leads")
Enter fullscreen mode Exit fullscreen mode

Exact matching after normalization misses some real matches and almost never makes a wrong one. If you need more recall, add a fuzzy matcher, and review its matches by hand before they go to sales. For the registry side of a join, use a wider window (issuedWithinDays: 0 or a year of issuedFrom), because a business may have registered long before its next permit.

Step 4: Run it daily without duplicates

  1. Save each input as an Actor task in Apify Console, for example ny-new-electrical-wny and permits-roofing-solar.
  2. Under Schedules → Create new, set a cron such as 0 7 * * *, pick your timezone, add both tasks, and enable the schedule. New schedules start disabled.
  3. Use a 2-day window (issuedWithinDays: 2) so nothing falls between runs, then de-duplicate in your destination:
    • registry key: certificateNumber
    • permit key: city + permitNumber
  4. Los Angeles refreshes weekly. If you include los_angeles, put it in a separate task with issuedWithinDays: 14 or more, run it weekly, and de-duplicate the same way. Otherwise LA will look empty most days.

To send rows to your tools, use each task's Integrations tab: Gmail (results attached as CSV/Excel), Google Drive file upload, Slack, webhooks, or Make/Zapier with a Google Sheets or CRM step. Daily SAM.gov Alerts in Your NAICS Codes (No API Key), the first post in this series, walks through each of these step by step.

Step 5: Do outreach like a professional

  • Lead with the signal. "Saw you just registered for public work in Erie County. Here's how we help prevailing-wage contractors with X" beats a generic pitch.
  • The data is business-only, so keep outreach business-only. Follow the email and calling rules that apply to you (for US businesses, at least CAN-SPAM for email and the TCPA and do-not-call rules for phone). Check with counsel if you're unsure.
  • Respect the sources' terms. The NY data is published under the OPEN-NY Terms of Use. If you republish Chicago data, Chicago's Data Terms of Use require a specific notice.
  • Don't treat a flag as a verdict. hasOutstandingWageAssessments and debarred are what the registry reports. Check the official record before you act on it.

The data gaps, all in one place

  • The NY registry covers public-work/prevailing-wage registrants only. Sole proprietors, and registrations whose name lacks an entity designator, are left out on purpose.
  • Registry keywords match names only.
  • LA permits refresh weekly. SF and LA don't publish contractor names. Seattle sometimes does, Boston rarely does.
  • NYC trade permits often lack a valuation, and minValuation drops permits without one.
  • workCategory is rule-based. Spot-check the categories you rely on, and use permitTypes when a city's own wording is more precise.

Disclosure: we built the two Actors used here, the New York Contractor Registry Feed ($3 per 1,000 results) and the US Building Permits Feed ($2 per 1,000 results), plus Apify's small Actor-start fee. Both read the official open-data APIs listed above, which you can also query yourself.


Update (Oct 2026): a data-backed follow-up for one state, New Washington contractor licenses in October 2026.

Top comments (0)