"Just query the Socrata API" is true and also useless advice. Socrata SODA is a real open standard — New York, Connecticut, Colorado, and Texas all expose their professional-license registries through the same $limit / $offset / $where query language. The standard ends there. What each state puts inside that standard is four unrelated data models wearing the same protocol.
Quick answer
Every state's cosmetology/barber/salon registry is one giant multi-profession table with its own column names, its own beauty-credential filter, and its own idea of what "active" means — and one state (Texas) doesn't expose a status column at all, so an activeOnly toggle is a silent no-op there. A generic Socrata client that assumes one schema will either miss most of the data or crash on the first state whose columns don't match. The fix is a per-state config object that maps each state's real column names to one canonical output row, with the active-license filter applied only where the underlying data supports it.
@dataclass(frozen=True)
class StateConfig:
state: str
endpoint: str
order_key: str
col_business_name: str | None
col_licensee_name: str | None
col_status: str | None
base_where: str | None = None
active_where: str | None = None
Why does the same query return different professions per state?
Cosmetology licenses don't get their own dataset — they're rows buried inside each state's entire professional-licensing table, next to electricians, dentists, and notaries. Filtering has to happen server-side, in SoQL, before pagination even starts, or you're downloading (and paying to store) irrelevant rows. Texas needs a starts_with() match across three license-type prefixes plus an Establishment wildcard; Connecticut needs an exact in() list of six credential names; Colorado needs a four-code in() list:
TX_BEAUTY_WHERE = (
"starts_with(license_type,'Cosmetology') "
"OR starts_with(license_type,'Class A Barber') "
"OR starts_with(license_type,'Barber') "
"OR license_type like '%Establishment%'"
)
CT_BEAUTY_WHERE = (
"credential in("
"'Hairdresser/Cosmetician','Barber','Esthetician','Nail Technician',"
"'Eyelash Technician','Combination Nail Technician, Esthetician or Eyelash Tech')"
)
CO_BEAUTY_WHERE = "licensetype in('COS','COZ','BAR','MAN')"
Three states, three completely different filter vocabularies, for the same category of license.
Why does Texas ignore the "active licenses only" toggle?
Because Texas's dataset has no status column to filter on. NY, CT, and CO each carry an active_where clause that gets ANDed into the query when the caller asks for active-only records. Texas's StateConfig simply has no active_where — there's nothing to apply. A client built against one state's shape and pointed at Texas would either throw a KeyError reaching for a nonexistent field, or worse, silently return expired and revoked licenses mixed in with active ones while the caller believes they filtered:
def _build_where(config: StateConfig, active_only: bool) -> str | None:
clauses: list[str] = []
if config.base_where:
clauses.append(f"({config.base_where})")
if active_only and config.active_where:
clauses.append(f"({config.active_where})")
if not clauses:
return None
return " AND ".join(clauses)
Why isn't "licensee name" just one column?
Because Colorado's registry doesn't have one. It has firstname and lastname as separate columns with no combined field, so the licensee's name has to be assembled at read time — and only for Colorado, since every other supported state ships a single name column:
def _resolve_licensee_name(raw: dict[str, Any], config: StateConfig) -> str | None:
if config.state == "CO":
parts = [(raw.get("firstname") or "").strip(), (raw.get("lastname") or "").strip()]
joined = " ".join(p for p in parts if p)
return joined or None
return _get(raw, config.col_licensee_name)
New York has the mirror problem on the address field: business_address_1 and business_address_2 need joining, and only New York — every other state's address is already one field.
Is scraping state open-data registries legal?
These are official government open-data portals, published specifically for public and commercial reuse, with no login and no rate-limit wall beyond Socrata's own throttling. We still treat every endpoint as a target that can rate-limit or reject a request: curl-cffi impersonates real Chrome, Firefox, and Safari TLS sessions, we retry 408 / 429 / 503 with exponential backoff up to 5 attempts, and residential proxy rotation is available for higher-volume runs.
FAQ
Which states does this cover?
New York, Connecticut, Colorado, and Texas — the four with cosmetology/barber/salon credentials published through a Socrata open-data API as of this writing.
Does activeOnly work the same way in every state?
No. It filters server-side for NY, CT, and CO. Texas's dataset has no status column, so the toggle has no effect there — every Texas row returned is whatever the registry currently has on file.
What's a "beauty establishment" row versus an individual license?
Some states license the salon/shop itself as a separate credential type from the individual cosmetologist or barber. Texas's filter explicitly includes an Establishment match for exactly this reason — leave it out and you only get individual operators, not the shops they work in.
Why would a row get dropped entirely?
If a record has neither a business name nor a resolvable licensee name after per-state normalization, it's skipped rather than emitted as a mostly-empty row.
Packaged and ready to run: Cosmetology & Salon License Leads — pick your states, toggle active-only, get one normalized row per license: business name, licensee name, license number, type, status, address, city, and county. $0.20 warm-up plus $0.004 per result row (about $4 for 1,000 leads).
We run the gauntlet so your lead list lands clean. 😈
Top comments (0)