# Texas TDLR License Scraper — Lookup, Verify & Business Leads (`scrapersdelight/tdlr-texas-license-scraper`) Actor

Texas TDLR licences from the state's own open-data registry. Verify a licensee by licence number, business or owner name, or pull the 99,720 contactable businesses in the 983,494-row file with phone, street address, county and expiry. 86 licence types and 254 counties as dropdowns. No login, no key.

- **URL**: https://apify.com/scrapersdelight/tdlr-texas-license-scraper.md
- **Developed by:** [Scrapers Delight](https://apify.com/scrapersdelight) (community)
- **Categories:** Lead generation, Business, Automation
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$3.00 / 1,000 per license returneds

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

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

## What's an Apify Actor?

Actors are web data automations that power AI and operations. They run on the Apify platform to scrape websites, process data, connect APIs, and automate workflows.
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.
Actors are written with capital "A".

## 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.
The best way to integrate Actors is as follows.

- **AI agents and MCP clients** — the [Apify MCP server](https://docs.apify.com/integrations/mcp.md) at `https://mcp.apify.com` (remote, streamable HTTP, OAuth on first use).
- **Agentic workflows and local Actor development** — [Agent Skills](https://apify.com/.well-known/agent-skills/index.json) with the [Apify CLI](https://docs.apify.com/cli/docs.md): `npm install -g apify-cli`, then `apify login`.
- **JavaScript/TypeScript projects** — the official [JS/TS client](https://docs.apify.com/api/client/js/docs.md): `npm install apify-client`.
- **Python projects** — the official [Python client](https://docs.apify.com/api/client/python/docs.md): `pip install apify-client`.
- **Any other language** — the [REST API](https://docs.apify.com/api/v2.md).

For usage examples, see the [API](#api) section below.

For more details, see Apify documentation as [Markdown index](https://docs.apify.com/llms.txt) and [Markdown full-text](https://docs.apify.com/llms-full.txt).

# README

## Texas TDLR License Scraper — Lookup, Verify & Business Leads

Texas trade and establishment licences straight from the state's own registry at
[data.texas.gov](https://data.texas.gov/resource/7358-krk7.json), published by the
[Texas Department of Licensing and Regulation](https://www.tdlr.texas.gov/LicenseSearch/).
**One row per licence**, carrying **licenseNumber, licenseType, licenseSubtype, businessName,
ownerName, phone, businessAddress, businessCity, businessState, businessZip, businessCounty,
mailingAddress, mailingCity, mailingZip, mailingCounty, expirationDate, daysUntilExpiry** and
**continuingEducationFlag**.

Two jobs, one Actor: **verify** a licensee by licence number, business name or owner name, or
**pull a lead list** filtered by licence type, county, city, ZIP and renewal window. No login, no
API key, no CAPTCHA solving, no browser automation — it reads the state's public JSON feed.

**983,494 licences, 86 licence types, 254 Texas counties. 99,720 of those rows (10.1%) carry a
phone number** — measured live against the registry on 2026-08-12, not quoted from a marketing
page. Those 99,720 are the licensed *businesses*; the other 883,774 are individual worker
registrations that TDLR publishes with no contact data at all.

**$0.003 per licence returned — $3 per 1,000.** No per-run fee, no monthly platform fee from this
Actor.

```json
{
  "licenseTypes": ["Electrical Contractor"],
  "counties": ["HARRIS", "DALLAS"],
  "maxItems": 100
}
```

Click **Try for free** and hit **Start** — that is the input the Actor ships with. It returns 100
Harris/Dallas electrical contractors with phone and street address in about 4 seconds, for $0.30.

***

### Quick start

1. **Sign up** for an Apify account (free credit included) and open this Actor.
2. **Press Start.** The prefilled input already works; you do not have to edit anything.
3. **To go wider:** clear *Counties*, pick more *Licence types* from the dropdown (each entry shows
   its measured row count and whether it carries contact data), raise *Max results*.
4. **To verify instead of prospect:** paste licence numbers, or a business/owner name, into the
   **Verify specific licensees** section.
5. **Download** from the **Dataset** tab as CSV, Excel, JSON or XML, or pull it over the API.

Time from a cold account to a downloaded CSV: a couple of minutes. No code required.

**Call it from code** (Apify API — swap in your token and the input you want):

```python
from apify_client import ApifyClient
client = ApifyClient("<YOUR_APIFY_TOKEN>")
run = client.actor("scrapersdelight/tdlr-texas-license-scraper").call(run_input={
    "licenseTypes": ["Full Service Establishment"],
    "counties": ["BEXAR"],
    "maxItems": 500,
})
for row in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(row["businessName"], row["phone"], row["expirationDate"])
```

```javascript
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: '<YOUR_APIFY_TOKEN>' });
const run = await client.actor('scrapersdelight/tdlr-texas-license-scraper').call({
    licenseNumbers: ['17000'],
    licenseTypes: ['Electrical Contractor'],
});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items[0]);
```

```bash
## single-licence verification, synchronous, results straight back
curl -X POST "https://api.apify.com/v2/acts/scrapersdelight~tdlr-texas-license-scraper/run-sync-get-dataset-items?token=<YOUR_APIFY_TOKEN>" \
  -H 'Content-Type: application/json' \
  -d '{"licenseNumbers":["17000"],"licenseTypes":["Electrical Contractor"]}'
```

***

### The wedge: the whole file is filterable server-side, and the dropdowns are the real lists

Every enumerable filter in this Actor ships as a **dropdown built from the live registry**, with the
measured row count and a ✅/❌ contact-data marker on every entry:

- **86 licence types** — `Full Service Establishment — 35,584 rows · ✅ 35,566 with phone`,
  `Apprentice Electrician — 253,611 rows · ❌ no phone/address`.
- **255 county values** — `HARRIS — 15,462 contactable of 161,937`, including TDLR's own
  `OUT OF STATE` bucket (49,395 rows, 2,642 contactable).

That means you can see what a slice is worth **before** you spend anything, and a mistyped licence
type cannot silently return zero rows. A custom licence type you type is validated against the live
registry before the walk starts, and a city or ZIP that empties the result stops the run with the
real names it found instead of handing back an empty dataset.

The filtering itself happens **server-side at data.texas.gov**, so a county × trade × expiry slice
costs one HTTP request, not a 983,494-row download you then filter yourself.

***

### Read this before you run it

1. **Most of TDLR's file is not contactable.** 983,494 licences; 99,720 (10.1%) have a phone,
   108,037 (11.0%) have a street address. `withContactOnly` therefore defaults to **true**. The
   four biggest licence types — Apprentice Electrician (253,611), Cosmetology Operator (197,266),
   Cosmetology Manicurist (70,377), A/C Technician (59,405) — are individual worker registrations
   at **0.0% phone**. Nobody sells you 983,494 rows here, because 883,774 of them have no way to
   contact anyone.
2. **A TDLR licence number is not a unique id.** On a contiguous 10,000-row walk, 2,236 rows
   (22.4%) carried a licence number that had already appeared under a different licence type.
   Licence number `109` alone returns 7 rows across 6 licence types — including *two different*
   "Service Contract Provider" #109 businesses. Always read `licenseNumber` together with
   `licenseType` and `businessName`.
3. **Verification lookups deliberately ignore the contact-only filter.** If you paste a licence
   number and TDLR publishes no phone for it, you still get the row. A lookup that returned nothing
   would read as "not licensed", which is the wrong answer.
4. **The registry is a periodic snapshot, not a live feed.** When checked on 2026-08-12, Socrata
   reported the file's last data update as **2026-07-16**. TDLR publishes no refresh schedule.
   Expect the export to lag the board's live record by weeks — this is fine for lead generation and
   worth stating out loud if you use it for compliance.
5. **5.6% of rows have no licence type at all.** 54,932 of 983,494 rows have `license_type` null in
   TDLR's own file. Any run that filters by licence type excludes them by definition.

***

### What you get

One row per licence. All dates are ISO-8601 (`YYYY-MM-DD`); `daysUntilExpiry` is a signed integer
computed at run time and goes **negative for already-expired licences**.

**Identity**

| Field | Type | Notes |
|---|---|---|
| `licenseNumber` | string | TDLR licence number. Unique only *within* a licence type — see gotcha #2 |
| `licenseType` | string | One of TDLR's 86 types, e.g. `Electrical Contractor` |
| `licenseSubtype` | string | null | TDLR's short code, e.g. `EC`, `CS`, `REG` |
| `businessName` | string | null | The licensed business |
| `ownerName` | string | null | Owner, stored `LAST, FIRST` for individuals |

**Contact**

| Field | Type | Notes |
|---|---|---|
| `phone` | string | null | Normalised from TDLR's raw `17135622862` to `(713) 562-2862` |
| `hasContact` | boolean | `true` when a phone was published — the flag `withContactOnly` filters on |

**Business address**

| Field | Type | Notes |
|---|---|---|
| `businessAddress` | string | null | Street line 1 |
| `businessAddress2` | string | null | Suite/unit line, sparse |
| `businessCity` | string | null | Parsed out of TDLR's single `CITY ST ZIP` column |
| `businessState` | string | null | Two-letter |
| `businessZip` | string | null | ZIP5 or ZIP+4, as published |
| `businessCounty` | string | null | TDLR's own county value, or `OUT OF STATE` |

**Mailing address**

| Field | Type | Notes |
|---|---|---|
| `mailingAddress`, `mailingCity`, `mailingState`, `mailingZip`, `mailingCounty` | string | null | Often a PO box where the business address is a storefront |

**Licence status**

| Field | Type | Notes |
|---|---|---|
| `expirationDate` | string | null | ISO-8601 |
| `daysUntilExpiry` | integer | null | Negative = already expired |
| `continuingEducationFlag` | string | null | TDLR's `Y`/`N` CE flag |

**Provenance**

| Field | Type | Notes |
|---|---|---|
| `sourceUrl` | string | `https://www.tdlr.texas.gov/LicenseSearch/` |
| `scrapedAt` | string | ISO-8601 UTC timestamp of the run |

***

### Field fill — measured on real runs

**Table A — 13,501 rows, all licence types, contact-bearing, run on Apify 2026-08-12.** This is a
contiguous walk in `license_number` order across the whole contactable corpus, so it mixes salons,
electrical contractors, recyclers, elevator contractors and the rest.

| Field | Fill |
|---|---:|
| **licenseNumber**, **licenseType**, **businessName**, **ownerName**, **phone**, **expirationDate**, `daysUntilExpiry`, `continuingEducationFlag`, `hasContact`, `sourceUrl`, `scrapedAt` | **100.0%** |
| **businessAddress**, **businessCity**, **businessCounty**, `mailingCounty` | 99.1% |
| **businessState**, **businessZip** | 98.5% |
| `mailingAddress`, `mailingCity` | 98.2% |
| `mailingState`, `mailingZip` | 97.5% |
| `licenseSubtype` | 92.9% |
| `businessAddress2` | 4.7% |

**Table B — 10,000 Full Service Establishments (salons and spas), run on Apify 2026-08-13.** Plan
against this one if you are buying a single establishment type.

| Field | Fill |
|---|---:|
| **licenseNumber**, **licenseType**, `licenseSubtype`, **businessName**, **ownerName**, **phone**, **expirationDate**, `daysUntilExpiry`, `continuingEducationFlag`, `hasContact` | **100.0%** |
| **businessAddress**, **businessCity**, **businessCounty**, `mailingCounty` | 99.8% |
| **businessState**, **businessZip** | 99.8% |
| `mailingAddress`, `mailingCity` | 98.3% |
| `mailingState`, `mailingZip` | 97.7% |
| `businessAddress2` | 11.3% |

**The headline that could mislead you: phone is 100% only *because* `withContactOnly` is on.** Turn
it off and phone fill across the whole 983,494-row file is **10.1%**, street address **11.0%**. The
filter is not hiding a data problem — it is the entire reason the product is honest about its size.

Across the whole contactable corpus (all 99,720 rows, counted server-side 2026-08-12): business
street address 99.3%, business city/state/ZIP 99.3%, mailing address 92.2%, licence subtype 98.5%.

**Phone normalisation:** 13,496 of 13,501 rows (99.96%) normalised cleanly to `(NNN) NNN-NNNN`. The
5 that did not are kept exactly as TDLR published them because they are not ten digits —
`9419252990 X 20`, `8005626463x2131`, `95482995952`. Nothing is invented and nothing is dropped.

Every run prints its own row count and request count in the log.

***

### How to run it

#### Bulk lead pull (the default)

```json
{
  "licenseTypes": ["Full Service Establishment", "Mini Establishment"],
  "counties": ["BEXAR"],
  "withContactOnly": true,
  "maxItems": 2000
}
```

Salons and spas in Bexar County with phone and street address. Cost: up to $6.

#### Verify one licensee, or a batch of them

```json
{
  "licenseNumbers": ["17000", "109", "508016"],
  "maxItems": 100
}
```

Look up many in **one** run instead of starting a run per lookup — billing is unchanged, you are
charged per record returned, not per number submitted. The contact-only filter is ignored here, so
you see the licence even when TDLR publishes no phone for it. Add a `licenseTypes` entry to narrow
a reused number to the trade you care about.

#### Search by business or owner name

```json
{
  "businessName": "TORRES ELECTRIC",
  "licenseTypes": ["Electrical Contractor"]
}
```

Case-insensitive substring, run server-side. `businessName` and `ownerName` combine with **OR** with
each other and with `licenseNumbers`; the licence-type and location filters apply with **AND** on
top.

#### Territory slice by city or ZIP

```json
{
  "licenseTypes": ["Electrical Contractor"],
  "cities": ["SPRING", "KATY"],
  "maxItems": 500
}
```

City matching is exact on TDLR's `CITY ST ZIP` column, so `SPRING` returns Spring and **not** Spring
Branch. `zipCodes` takes 5-digit ZIPs and matches ZIP+4 forms too. Paste a whole territory list in.

#### Renewal-trigger monitor

```json
{
  "licenseTypes": ["Electrical Contractor"],
  "expiringWithinDays": 60,
  "maxItems": 1000
}
```

Only licences expiring between today and 60 days out. This filter runs **server-side**, so the
"matched your filters" count in the log is the real number and you are never billed for a row
outside the window. Point an Apify **Schedule** at it weekly and you have a standing renewal feed.
For *already-expired* licensees instead, leave `expiringWithinDays` at 0 and filter the dataset on
`daysUntilExpiry < 0` — 2,552 of the 13,501 rows in Table A (18.9%) were already past expiry.

#### Continuing a run that stopped at your budget

Rows are charged as they are pushed. If a run hits its **Max total charge** it stops on a whole row,
logs how many it delivered, and tells you so — it does not half-bill a dataset. Raise the cap in the
run options, or lower `maxItems`, and re-run with the same filters.

***

### Sample rows

A real row from the default input, pulled 2026-08-13:

```jsonc
{
  "licenseNumber": "17000",
  "licenseType": "Electrical Contractor",
  "licenseSubtype": "EC",

  "businessName": "TORRES ELECTRICAL SERVICE",
  "ownerName": "TORRES ELECTRICAL SERVICE",

  "phone": "(713) 562-2862",          // normalised from TDLR's raw 7135622862
  "hasContact": true,

  "businessAddress": "11910 DOVER STREET",
  "businessAddress2": null,
  "businessCity": "HOUSTON",
  "businessState": "TX",
  "businessZip": "77031-2516",
  "businessCounty": "HARRIS",

  "mailingAddress": "11910 DOVER ST",
  "mailingCity": "HOUSTON",
  "mailingState": "TX",
  "mailingZip": "77031-2516",
  "mailingCounty": "HARRIS",

  "expirationDate": "2026-07-31",
  "daysUntilExpiry": -13,             // negative: this licence has already expired
  "continuingEducationFlag": "N",

  "sourceUrl": "https://www.tdlr.texas.gov/LicenseSearch/",
  "scrapedAt": "2026-08-13T02:03:57.957Z"
}
```

A salon row from the 10,000-row Full Service Establishment run:

```jsonc
{
  "licenseNumber": "508016",
  "licenseType": "Full Service Establishment",
  "licenseSubtype": "CS",
  "businessName": "MARY'S BEAUTY SHOP",
  "ownerName": "MARY'S BEAUTY SHOP",
  "phone": "(281) 328-1667",
  "businessAddress": "12218 LLOYD",
  "businessCity": "CROSBY",
  "businessState": "TX",
  "businessZip": "77532",
  "businessCounty": "HARRIS",
  "mailingZip": "77532-2611",
  "expirationDate": "2027-02-28",
  "daysUntilExpiry": 199,
  "continuingEducationFlag": "Y",
  "hasContact": true
}
```

And what a verification lookup on the reused number `109` actually returns — 7 rows, 6 licence
types, two different "Service Contract Provider" businesses, two with no phone at all:

```
109 | Boiler Authorized Inspection Agency  | CINCINNATI INSURANCE CO., THE            | null           | OUT OF STATE
109 | Barber School                        | EAST TEXAS BARBER SCHOOL INC             | (903) 593-4424 | SMITH
109 | Licensed Breeder                     | TNT KENNEL                               | (214) 477-3811 | VAN ZANDT
109 | Electrician Apprenticeship Program   | ASSOCIATED BUILDERS & CONTRACTORS SO TEX | (210) 342-1994 | BEXAR
109 | Service Contract Provider            | MERCURY SELECT MANAGEMENT COMPANY INC    | (405) 621-6179 | OUT OF STATE
109 | Tow Truck Operator (Consent Tow)     | BRANDL, CONNIE                           | null           | VICTORIA
109 | Service Contract Provider            | FIRST GUARD WARRANTY CORPORATION         | (512) 502-1496 | TRAVIS
```

Fields people misread:

- `daysUntilExpiry` is **signed**. `-13` means expired 13 days ago; `null` means TDLR published no
  expiry date on that row.
- `phone: null` means TDLR publishes no phone for that licensee, not that a fetch failed.
  `hasContact` is the flag to filter on.
- `businessCounty` may be the literal string `OUT OF STATE` — that is TDLR's own value for a
  licensee with a non-Texas business address, not a parsing failure.
- `businessAddress` is a storefront; `mailingAddress` is frequently a PO box. They differ on plenty
  of rows and both are published.

***

### Input

Fields are listed in the order they appear in the Console.

| Field | Type | Default | What it does |
|---|---|---|---|
| `licenseTypes` | multi-select | `["Electrical Contractor"]` | 86 TDLR licence types, each labelled with its measured row count and a ✅/❌ contact-data marker. Empty = every type. Picking from the list is typo-proof |
| `licenseTypeCustom` | string | `""` | Escape hatch for a type not yet in the dropdown. Validated against the live list — a typo errors immediately with the closest real names |
| `counties` | multi-select | `["HARRIS","DALLAS"]` | 254 Texas counties plus `OUT OF STATE`, each labelled with its contactable count. Empty = statewide |
| `cities` | string list | `[]` | Business cities, exact match on TDLR's `CITY ST ZIP`. 1,910 distinct Texas cities appear on the contactable rows |
| `zipCodes` | string list | `[]` | 5-digit business ZIPs; matches ZIP+4 too. 3,405 distinct ZIP5s on the contactable rows |
| `licenseNumbers` | string list | `[]` | Batch verification. Switches off the contact-only filter for the run. Billed per record returned, not per number submitted |
| `businessName` | string | `""` | Case-insensitive substring of the business name. OR'd with the other two lookup fields |
| `ownerName` | string | `""` | Case-insensitive substring of the owner name (`LAST, FIRST`) |
| `withContactOnly` | checkbox | `true` | Keep only rows with a phone — 99,720 of 983,494. Ignored in verification lookups |
| `expiringWithinDays` | integer | `0` | 0 = off. 1–365 = only licences expiring in that window. Applied server-side, so out-of-window rows are never fetched and never billed |
| `maxItems` | integer | `100` | Hard cap on rows returned, and therefore your cost ceiling: 100 = $0.30, 1,000 = $3 |
| `proxyConfiguration` | proxy | off | Off on purpose — see the transport section |

#### The licence types that carry contact data

42 of TDLR's 86 licence types publish at least one phone number. These are the ones worth pulling
for lead work (rows / of which contactable, measured 2026-08-12):

`Full Service Establishment` 35,584 / 35,566 · `Mini Establishment` 21,616 / 21,612 ·
`Electrical Contractor` 13,917 / 13,911 · `Manicurist/Esthetician Establishment` 7,690 / 7,690 ·
`Devices` 13,518 / 5,128 · `Esthetician Establishment` 4,768 / 4,763 ·
`Water Well Driller/Pump Installer` 1,740 / 1,709 · `Manicurist Establishment` 857 / 855 ·
`Appliance Installation Contractor` 835 / 835 · `Used Auto Parts Recycler` 740 / 740 ·
`Eyelash Extension Establishment` 666 / 666 · `Electrical Sign Contractor` 654 / 653 ·
`Registered Accessibility Specialist` 645 / 640 · `Cosmetology Private School` 640 / 637 ·
`Service Contract Provider` 577 / 574 · `Water Well Driller/Pump Installer Apprentice` 1,002 / 459 ·
`Professional Employer Organization` 432 / 432 · `Elevator Contractor` 365 / 361 ·
`Distributor/Wholesaler` 331 / 331 · `Cosmetology Vocational/High School` 257 / 257 ·
`Cosmetology CE Provider` 235 / 235 · `Licensed Breeder` 185 / 185 ·
`Electrician CE Provider` 184 / 184 · `Mobile Establishment` 169 / 168 · `Barber School` 132 / 131 ·
`Service Company` 103 / 103 · `Hair Weaving Establishment` 85 / 85 · `Supplier` 85 / 85 ·
`A/C CE Provider` 79 / 79 · `Electrician Apprenticeship Program` 70 / 70 · plus twelve smaller ones.

`Devices` deserves a note: it is a site registration held mostly by convenience stores and fuel
retailers (LILLY FOOD 2, MURPHY EXPRESS #8751, QUICK MART), and it is the one large type where fill
is partial — 5,128 of 13,518 rows (37.9%) carry a phone.

#### The big licence types with no contact data at all

Mostly individual worker registrations. TDLR publishes a name and an expiry date and nothing else —
no phone, no address, on any row of any of them:

`Apprentice Electrician` 253,611 · `Cosmetology Operator` 197,266 · `Cosmetology Manicurist` 70,377 ·
`A/C Technician` 59,405 · `Cosmetology Esthetician` 47,683 · `Journeyman Electrician` 44,623 ·
`Class A Barber` 30,405 · `A/C Contractor` 20,323 · `Master Electrician` 19,745 ·
`Cosmetology Manicurist/Esthetician` 12,136 · `Tow Truck Operator (Incident Management)` 11,856 ·
`Licensed Behavior Analyst` 9,023 · `Tow Truck Operator (Consent Tow)` 6,999 ·
`Cosmetology Eyelash Extension Specialist` 5,471 · `Apprentice Sign Electrician` 5,294 ·
`Property Tax Appraiser` 3,962 · `VSF Employee` 3,504 · `Property Tax Consultant` 2,663 ·
`Residential Wireman` 2,535 · `Appliance Installer` 2,508 · `Auctioneer` 1,839.

Note that `A/C Contractor` (20,323) and `A/C Technician` (59,405) are both in this list: TDLR
publishes **no phone and no address for any of them**. If you came here for an HVAC contact list,
this file does not contain one — the contactable trades here are the electrical, cosmetology,
appliance, water-well, elevator, accessibility and auto-recycling ones.

***

### Pricing

**$0.003 per licence returned — $3 per 1,000.** Charged on the `license-scraped` event. No per-run
fee and no monthly platform fee from this Actor.

| Run | Rows | Cost |
|---|---:|---:|
| Default smoke test (Harris + Dallas electrical contractors) | 100 | $0.30 |
| Every electrical contractor in Texas | 13,917 | $41.75 |
| Every salon and spa in Texas (`Full Service Establishment`) | 35,584 | $106.75 |
| The whole contactable corpus | 99,720 | $299.16 |

$5 of Apify credit is about 1,666 licences.

What you are **not** charged for:

- Rows dropped as duplicates. Deduplication happens before the push, so the dataset — and your bill
  — never holds the same published record twice.
- Rows outside your `expiringWithinDays` window, your county, your city or your ZIP. Every filter
  runs server-side at data.texas.gov, so those rows are never even fetched.
- A run that matches nothing. It throws before writing a row, so nothing is delivered and nothing is
  billed.
- The pre-run count query and the filter-validation queries. Those are ours, not yours.

Rows are charged **as they are pushed** (`Actor.pushData(items, 'license-scraped')`), so if you hit
a budget cap you get whole rows and stop, not a half-billed dataset. Verified on 2026-08-13: a run
asked for 20,000 rows, hit its $40.32 max-total-charge, stopped at **13,439** rows, charged exactly
**13,439** events, wrote exactly **13,439** dataset items, and said so in the log.

`maxItems` is your hard cost cap (default 100).

***

### Honest limits

- **Only 10.1% of TDLR's file is contactable.** 99,720 rows of 983,494 carry a phone; 108,037
  (11.0%) carry a street address. This is what the state publishes, not a scraping shortfall — the
  registry's four biggest licence types are individual workers with 0.0% contact fill. If you need
  contactable *people* rather than businesses, this file cannot give them to you and no Actor
  reading it can.
- **There are no email addresses.** TDLR publishes none, for any licensee, in this dataset. Anyone
  selling you "TDLR emails" generated them somewhere else. `website` is not published either. The
  handle to enrich from is `businessName` + `businessAddress` + `phone`.
- **There is no licence *status* column.** This export carries an expiry date, not an
  active/suspended/revoked flag, and no disciplinary or complaint history. `daysUntilExpiry < 0`
  tells you a licence has lapsed in the snapshot; it does not tell you a licence was revoked. For a
  status determination, use TDLR's own [licence search](https://www.tdlr.texas.gov/LicenseSearch/).
- **The snapshot lags.** Socrata reported the file's last data update as 2026-07-16 when checked on
  2026-08-12, and TDLR publishes no refresh schedule.
- **5.6% of rows carry no licence type** (54,932 of 983,494) and **3.8% no business county** (37,776
  rows). Filtering by type or county excludes them.
- **TDLR misspells its own cities.** 692 of the 1,910 distinct city spellings on the contactable
  rows appear exactly once, and some are plainly typos — `HOUTON`, `HOUSTPN`, `HOUSON` and
  `HOUSTONTX` put 8 rows outside a `HOUSTON` city filter on the contactable slice, 15 across the
  whole file, and there are further variants (`HOUSOTN`, `HOUSTN`, `HOUSWTON`, `HOUSTON TEXAS`).
  If a city slice matters, cross-check with `counties` or `zipCodes`, which are cleaner.
- **We do not emit latitude/longitude, and that is deliberate.** TDLR ships a geo point on 98.5% of
  contactable rows, but it is a city centroid: 400 sampled Houston rows spread across 87 different
  ZIP codes all carry the identical coordinate `-95.36952, 29.76078`. Publishing that as a business
  location would be misleading, so this Actor drops it. Geocode `businessAddress` yourself if you
  need real coordinates.
- **`businessState` and `businessZip` come from parsing one column.** TDLR stores the city, state
  and ZIP together as `HOUSTON TX 77031-2516`. 99.1% of contactable rows parse cleanly; the 180 rows
  (0.2%) that do not keep the raw string in `businessCity` with `businessState` and `businessZip`
  null, rather than guessing.
- **No login, no API key, no CAPTCHA solving, no browser automation.** There is nothing to defeat
  here and this Actor does not pretend otherwise — it reads a public open-data endpoint.

***

### How it works, and the transport ladder

One endpoint: `GET https://data.texas.gov/resource/7358-krk7.json`, the Socrata open-data mirror of
TDLR's full licence file. The Actor:

1. Reads the live `license_type` and `business_county` lists and validates whatever you selected or
   typed against them, before spending anything.
2. Builds a single SoQL `$where` from your licence types, counties, cities, ZIPs, lookup terms,
   contact filter and expiry window, and asks for a `count(*)` first so the log states the real
   match count.
3. Walks pages of up to 1,000 rows with `$order=license_number` pinned, pushing and charging each
   page as it lands.

**Why `$order` is not optional:** Socrata does not guarantee a stable row order across pages unless
you pin one, and offset paging without it can silently repeat and skip rows.

**Why the expiry filter is an explicit date list:** `license_expiration_date_mmddccyy` is stored as
text in `MM/DD/CCYY`, so a range comparison would sort lexicographically and be wrong. The Actor
sends the actual calendar dates in your window as a SoQL `IN (…)` list — exact, and it keeps the
pre-run count honest.

#### Transport, measured through Apify

`data.texas.gov` is **not walled**: no login, no key, no anti-bot, no geo gate. So the default is the
cheapest possible rung — no proxy at all.

| Rung | Result |
|---|---|
| Apify container, **direct, no proxy** — three runs on 2026-08-12/13 | **44 content requests, 44 successes = 100%**, zero retries |
| Apify Proxy | The first build of this Actor died on `proxy responded with 590 UPSTREAM502` while the direct request succeeded. Left available for anyone who needs a fixed egress IP, off by default |
| Home broadband, direct (a labelled non-rung — no Apify container has a home IP) | 20 contiguous pages, 20/20 successes. Reported, then ignored |

Sustained load, on Apify: **20,000 rows in 20 content requests in 12.4 seconds of walking**
(2026-08-12, 1 GB container), then a 10,000-row run that finished in 7.9 seconds wall-clock
end to end, then 13,439 rows in 14 requests. No retries fired in any of the three. That is roughly 1,600 rows/second, and it is why there is no concurrency
or delay knob in the input: the bottleneck is not the site.

***

### Uniqueness and dedupe

Measured on **contiguous** page walks — sparse offset sampling proves nothing here.

- **`license_number` alone is not unique.** On a contiguous 10,000-row walk, 2,236 rows (22.4%)
  repeated a licence number already seen under a different licence type.
- **`license_number` + `license_type` is not unique either.** On a contiguous 20,000-row walk it
  collided 43 times — and every single one of those 43 collisions was **two genuinely different
  businesses**. `Service Contract Provider` #109 is both MERCURY SELECT MANAGEMENT COMPANY and
  FIRST GUARD WARRANTY CORPORATION. A number+type dedupe key would silently delete real licensees
  from your dataset.
- **The key this Actor uses is the row's full published identity** — number, type, subtype, business
  name, owner name, phone, street, city, ZIP and expiry. Over the same 20,000 contiguous rows it
  found **0 duplicates**, and on the 13,501-row Apify run it correctly kept the 35 number+type
  collisions that were distinct businesses.
- Deduplication happens **before the push**, so a duplicate is never charged and never counts
  against `maxItems`.
- **Across runs**, dedupe by `licenseNumber` + `licenseType` + `businessName` on your side. Two runs
  with the same filters return the same rows; there is no cursor to resume from because the source
  is a static snapshot, not a stream.

***

### When a run fails

This Actor is built to fail loudly rather than hand back a quiet empty dataset.

- **A licence type or county that TDLR does not use stops the run before anything is billed**, with
  the live list. Typing `Electrical Contractr` errors with: *"Unknown licence type(s). TDLR publishes
  86 licence types. Closest live names: Apprentice Electrician | Apprentice Sign Electrician |
  Electrical Contractor | Electrical Sign Contractor …"*
- **A city or ZIP that matches nothing** is checked individually and the error names near matches:
  *"city 'HOUSTONN' matches nothing in TDLR's file — did you mean: HOUSTON, HOUSWTON, HOUATON …"*
- **Zero matching rows throws**, printing the exact SoQL filter in force so you can see which clause
  emptied it. Nothing is written and nothing is billed.
- **Zero rows emitted despite a non-zero pre-run count throws**, because that would mean something
  broke between the count and the walk.
- **A run stopped by its budget cap says so explicitly** and reports how many whole rows it
  delivered — it never claims rows it did not write.
- Socrata occasionally 502s. Each request retries up to four times with backoff before the run
  fails.

There is no "legitimate zero" mode in this Actor: if it returns nothing, that is an error and it
tells you why.

***

### Who buys this

- **Beauty-industry suppliers and salon software** — Boulevard, Vagaro, GlossGenius, distributors of
  professional product. `Full Service Establishment` (35,566 contactable), `Mini Establishment`
  (21,612) and `Manicurist/Esthetician Establishment` (7,690) is a 64,868-row Texas salon list with
  a phone on every row.
- **Electrical wholesalers and trade SaaS** — ServiceTitan, Housecall Pro, Jobber, supply houses.
  13,911 licensed electrical contractors with a street address to route a rep to.
- **Insurance and surety underwriters** — verify a Texas trade licensee by number or business name
  before binding, in one API call, without a portal seat.
- **General contractors and marketplaces vetting subs** — paste the licence numbers off a bid packet
  and get back type, county, owner and expiry, including for licensees with no published phone.
- **Renewal and re-engagement marketers** — `expiringWithinDays` is a dated trigger, and
  `daysUntilExpiry < 0` is a lapsed-licensee list (18.9% of a mixed 13,501-row sample).
- **Auto recyclers and parts platforms** — every one of the 740 `Used Auto Parts Recycler` licences
  carries a phone.
- **Elevator, accessibility and water-well specialists** — small, complete national-scale-impossible
  lists: 361 elevator contractors, 640 registered accessibility specialists, 1,709 water-well
  drillers, all with contact data.

***

### Which of our licence Actors to choose

| Actor | What it is | Why you would use it instead |
|---|---|---|
| [California Contractor License Scraper (CSLB)](https://apify.com/scrapersdelight/cslb-contractor-scraper) | California contractor licences | You need California, and you want bond and workers-comp data — which TDLR does not publish |
| [Florida Contractor License Scraper (DBPR)](https://apify.com/scrapersdelight/florida-dbpr-license-scraper) | Florida DBPR licence records | Same job, Florida |
| [NPI Registry Scraper](https://apify.com/scrapersdelight/npi-registry-scraper) | US healthcare providers | You want clinicians nationwide rather than one state's trades |
| [City Business License Monitor](https://apify.com/scrapersdelight/city-business-license-monitor) | New business licences by city | You want *new* openings as a trigger, not the standing roster |
| [New Liquor License Monitor](https://apify.com/scrapersdelight/liquor-license-monitor) | New bar/restaurant licences | Hospitality openings specifically |

Use this Actor when the answer has to come from **Texas's own registry** — either because you are
verifying a specific licensee, or because you want the trades and establishments TDLR regulates with
a phone number attached.

***

### FAQ

**Does this need an account, a login or an API key?**
No. `data.texas.gov` is a public open-data endpoint. The Actor sends no credentials and logs into
nothing.

**Do I need a proxy?**
No, and it is off by default. Three Apify runs made 44 content requests direct with 44 successes and
zero retries. Turn it on only if you need a fixed egress IP.

**Can I get the whole registry in one run?**
Yes — clear the licence types and counties and set `maxItems` high. All 983,494 rows with
`withContactOnly: false`; the 99,720 contactable ones with it on (about $299). Watch your run's
**Max total charge** setting, which will stop the run cleanly before `maxItems` if it is lower.

**How much does 1,000 rows cost?**
$3. The rate is $0.003 per licence returned, charged on the `license-scraped` event.

**Do I get charged for rows my filters remove?**
No. Every filter runs server-side at data.texas.gov, so filtered-out rows are never fetched. A run
that matches nothing throws before writing anything and bills nothing.

**Two runs — will I get duplicates?**
Within a run, no: duplicates are dropped on the row's full published identity before the push, so
they are never billed. Across runs with the same filters you get the same rows, because the source
is a static snapshot. Dedupe on `licenseNumber` + `licenseType` + `businessName` on your side.

**Why does one licence number return several rows?**
Because TDLR reuses licence numbers across licence types — 22.4% of rows on a 10,000-row walk did
so, and even number+type is reused by different businesses. Add a `licenseTypes` value to narrow it.

**Does it return email addresses or websites?**
No. TDLR publishes neither in this dataset, for any licensee. Nothing can produce them from this
source.

**Does it tell me whether a licence is active, suspended or revoked?**
No. The export carries an expiry date, not a status flag, and no disciplinary history. Use
`daysUntilExpiry` for lapsed-in-snapshot, and TDLR's own licence search for a status determination.

**How fresh is the data?**
Socrata reported the file's last data update as 2026-07-16 when checked on 2026-08-12. TDLR
publishes no refresh schedule, so treat it as a snapshot that lags by weeks.

**Can I schedule it?**
Yes. Point an Apify Schedule at it with `expiringWithinDays` set and you get a standing weekly feed
of licences coming up for renewal.

**Will a run ever succeed with zero rows?**
No. Zero matches throws, with the exact filter that emptied the result printed in the error.

**Something looks wrong — how do I debug it?**
The run log prints the SoQL match count before the walk, the emitted count after every page, and
the total request count at the end. If a filter looks wrong, the error messages name the live values
the registry actually publishes.

**Does it work on the Apify Free plan?**
Yes. The default input returns 100 rows in about 4 seconds for $0.30.

***

### Legal and fair use

This Actor reads a **public open-data endpoint** published by the Texas Department of Licensing and
Regulation on the State of Texas open-data portal. It does not log in, does not solve CAPTCHAs, does
not run browser automation, and collects nothing behind any authentication.

Texas professional licence records are public record. Some rows are sole proprietors and individuals
trading under their own name, so a row can constitute personal data — treat it accordingly. **You
are responsible for complying with TDLR's and data.texas.gov's terms and with how you use the
data**, including TCPA and state telemarketing rules for phone outreach, CAN-SPAM for email, and
GDPR/CCPA where they apply to you.

TDLR® and the Texas Department of Licensing and Regulation are marks of their owner. This Actor is
an independent tool and is **not affiliated with, endorsed by or sponsored by** TDLR or the State of
Texas.

***

### Changelog

| Version | Date | Change |
|---|---|---|
| 0.1.4 | 2026-08-13 | Licence-number / business-name / owner-name verification lookups; city and ZIP filters; licence type and county became live-validated dropdowns with measured counts; expiry filter moved server-side; dedupe key widened to the row's full identity after measuring that number+type dropped 43 real businesses per 20,000 rows; budget-cap stop now reports honestly instead of over-claiming; `maxItems` default 5,000 → 100 |
| 0.1.0 | 2026-08-11 | First release: licence type and county filters, contact-only mode, expiry window |

Verified against the live registry on **2026-08-13**.

***

### Feedback

Found a missing field, want a filter that is not here, or hit a licence type the dropdown does not
carry? Open an issue on the **Issues** tab — they get read. If the Actor saved you time, a rating on
the store page helps other people find it.

***

**Keywords:** texas tdlr license scraper · tdlr license search · texas license lookup · tdlr license
verification · texas electrical contractor list · texas electrician license lookup · texas cosmetology
license · salon owner leads texas · texas salon list · texas spa leads · data.texas.gov api · texas
licensing and regulation data · texas business license data · electrical contractor leads · appliance
installer leads · water well driller texas · elevator contractor texas · used auto parts recycler
texas · registered accessibility specialist · texas contractor verification · subcontractor vetting
texas · license expiration monitoring · renewal trigger leads · texas trade license api · tdlr open
data · texas licensee phone numbers · harris county contractors · dallas county contractors · bexar
county salons · travis county electricians · texas b2b lead list · licensed business directory texas

# Actor input Schema

## `licenseTypes` (type: `array`):

Pick one or more TDLR licence types. Types marked ✅ are business establishments and carry phone + street address; types marked ❌ are individual worker registrations (apprentice electricians, cosmetology operators) where TDLR publishes no contact data at all. Counts measured live on 2026-08-12. Leave empty for every type. Picking from this list is typo-proof — a hand-typed licence type that TDLR does not use stops the run.

## `licenseTypeCustom` (type: `string`):

Escape hatch: an exact TDLR licence-type string that is not in the dropdown, e.g. if TDLR adds one after this build. Added to whatever you picked above. Validated against the live list before the run starts, so a typo errors immediately with the closest real names instead of returning nothing.

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

Texas counties, as a multi-select. Each label shows how many of that county's licences carry a phone number, measured live on 2026-08-12. 'OUT OF STATE' is TDLR's own value for licensees with a non-Texas business address (49,395 rows, 2,642 contactable). Leave empty for statewide.

## `cities` (type: `array`):

Business cities, matched exactly against TDLR's 'CITY ST ZIP' field — 'SPRING' returns Spring and not Spring Branch. 1,910 distinct Texas cities appear on the 99,720 contactable rows; the biggest are HOUSTON (9,998), SAN ANTONIO (5,613), DALLAS (4,420), AUSTIN (3,723), FORT WORTH (2,640). Case-insensitive. A city that empties the result stops the run and prints the live city names closest to what you typed.

## `zipCodes` (type: `array`):

5-digit business ZIPs, e.g. 77031. Matches the ZIP+4 forms too. 3,405 distinct ZIP5s appear on the contactable rows. Paste a territory list straight in.

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

Paste TDLR licence numbers to verify many in ONE run instead of starting a run per lookup. Billing is unchanged — you are charged per record returned, not per number submitted. Note that a TDLR licence number is only unique within a licence type, and 22.4% of numbers are reused across types (measured on a 10,000-row contiguous walk), so one number can legitimately return several rows; add a licence type above to narrow it.

## `businessName` (type: `string`):

Case-insensitive substring of the licensed business name, e.g. 'TORRES ELECTRIC'. Runs server-side. Combined with the other two lookup fields using OR, and with the licence-type and location filters using AND.

## `ownerName` (type: `string`):

Case-insensitive substring of the owner name. TDLR stores owner names LAST, FIRST — 'TORRES' matches 4,850 rows across the whole file. Useful when the business trades under a person's name.

## `withContactOnly` (type: `boolean`):

Keep only records with a phone. Strongly recommended for lead work: of the 983,494 licences in TDLR's file only 99,720 (10.1%) have one and only 108,037 (11.0%) have a street address — the rest are individual worker registrations with no contact data at all. Ignored when you fill anything in the verification section above.

## `expiringWithinDays` (type: `integer`):

A dated renewal trigger: only licences whose expiry falls between today and N days from now. 0 turns the filter off. Applied server-side, so the 'matched your filters' count in the log is the real number and you are never billed for rows outside the window. Already-expired licences are excluded by this filter; leave it at 0 and read the daysUntilExpiry field instead (negative = already expired) if you want those.

## `maxItems` (type: `integer`):

Hard cap on licences returned, and therefore your cost ceiling: 100 = $0.30, 1,000 = $3, the whole contactable corpus (99,720) = about $299. Set it high and use the filters above to control scope.

## `proxyConfiguration` (type: `object`):

Off by default and that is deliberate: data.texas.gov is a public open-data API with no login, no key and no anti-bot, and the first build of this Actor died on 'proxy responded with 590 UPSTREAM502' while the direct request succeeded. A 20-page contiguous walk straight from an Apify container returned 20/20 successes. Only turn this on if you need a fixed egress IP.

## Actor input object example

```json
{
  "licenseTypes": [
    "Electrical Contractor"
  ],
  "licenseTypeCustom": "Electrical Sign Contractor",
  "counties": [
    "HARRIS",
    "DALLAS"
  ],
  "cities": [],
  "zipCodes": [],
  "licenseNumbers": [],
  "businessName": "ACME ELECTRIC",
  "ownerName": "TORRES",
  "withContactOnly": true,
  "expiringWithinDays": 0,
  "maxItems": 100,
  "proxyConfiguration": {
    "useApifyProxy": false
  }
}
```

# Actor output Schema

## `items` (type: `string`):

One row per license: business, owner, phone, address, county, license type and expiry.

# 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 = {
    "licenseTypes": [
        "Electrical Contractor"
    ],
    "counties": [
        "HARRIS",
        "DALLAS"
    ],
    "cities": [],
    "zipCodes": [],
    "licenseNumbers": [],
    "maxItems": 100
};

// Run the Actor and wait for it to finish
const run = await client.actor("scrapersdelight/tdlr-texas-license-scraper").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 = {
    "licenseTypes": ["Electrical Contractor"],
    "counties": [
        "HARRIS",
        "DALLAS",
    ],
    "cities": [],
    "zipCodes": [],
    "licenseNumbers": [],
    "maxItems": 100,
}

# Run the Actor and wait for it to finish
run = client.actor("scrapersdelight/tdlr-texas-license-scraper").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 '{
  "licenseTypes": [
    "Electrical Contractor"
  ],
  "counties": [
    "HARRIS",
    "DALLAS"
  ],
  "cities": [],
  "zipCodes": [],
  "licenseNumbers": [],
  "maxItems": 100
}' |
apify call scrapersdelight/tdlr-texas-license-scraper --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,scrapersdelight/tdlr-texas-license-scraper"
        }
    }
}

```

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/UTGRKFde2hMTBXIqa/builds/WJM3b9flgle962Pow/openapi.json
