Every building permit is a public record of someone about to spend money on construction: the address, the type of work, often the declared project value and the contractor doing it. If you sell building materials, dumpster rentals, insurance, solar, HVAC service plans — or you are a contractor watching your competitors — permits are the earliest buying signal that exists.
And most US cities publish them, daily, for free, on official open-data APIs. Not scraped, not resold, not 30 days stale: the city's own database.
This post shows how to pull them yourself with nothing but curl, what the data looks like, where it gets annoying (every city names its columns differently), and how to automate the whole thing.
Where permits live: the SODA API
Most large US cities run their open-data portals on Socrata, which exposes every dataset through the SODA API: plain HTTPS, JSON out, SQL-ish query parameters in. No API key required for moderate use.
Chicago's building permits, newest first:
curl "https://data.cityofchicago.org/resource/ydr8-5enu.json?\
\$order=issue_date%20DESC&\$limit=5"
Filter server-side with $where — say, permits issued this month:
curl "https://data.cityofchicago.org/resource/ydr8-5enu.json?\
\$where=issue_date%20%3E=%20'2026-07-01T00:00:00'&\
\$order=issue_date%20DESC&\$limit=100"
You get structured JSON with the permit number, work description, fees, reported cost, and — in Chicago's case — up to five named contacts including the contractor.
A few dataset IDs to get you started (all verified working as of July 2026):
| City | Portal | Dataset ID |
|---|---|---|
| New York City (DOB NOW) | data.cityofnewyork.us | rbx6-tga4 |
| Chicago | data.cityofchicago.org | ydr8-5enu |
| Los Angeles (2020+) | data.lacity.org | pi9x-tg5x |
| Austin | data.austintexas.gov | 3syk-w9eu |
| San Francisco | data.sfgov.org | i98e-djp9 |
| New Orleans | data.nola.gov | 72f9-bi28 |
To find any other city's dataset, query Socrata's catalog API:
curl "https://api.us.socrata.com/api/catalog/v1?domains=data.brla.gov&q=building%20permits&only=datasets"
The annoying part: every city is a special snowflake
Here's where the DIY route gets expensive. The issue date is issue_date in Chicago, issued_date in San Francisco, issuedate in New Orleans, and issueddate in Baton Rouge. Valuation is reported_cost, estimated_cost, valuation, estprojectcost, or declaredvaluation depending on who you ask. NYC's feed includes rows for permits that aren't issued yet, with the date simply missing. One city publishes latitude/longitude as columns, another nests them in a GeoJSON point, a third uses a geolocation object.
If you only care about one city, write the mapping once and move on. If you want a multi-city feed, you're maintaining a normalization layer — and re-verifying dataset IDs whenever a city migrates portals (NYC's old ipu4-2q9a dataset silently stopped updating in 2020; the live one is rbx6-tga4; Seattle and Dallas left Socrata entirely).
Here's a minimal normalizer for two cities to show the shape of the problem:
const CITIES = {
chicago: {
url: 'https://data.cityofchicago.org/resource/ydr8-5enu.json',
dateField: 'issue_date',
map: (r) => ({
permitNumber: r.permit_,
permitType: r.permit_type,
description: r.work_description,
issuedDate: r.issue_date?.slice(0, 10),
address: [r.street_number, r.street_direction, r.street_name].filter(Boolean).join(' '),
valuation: r.reported_cost ? Number(r.reported_cost) : null,
}),
},
'san-francisco': {
url: 'https://data.sfgov.org/resource/i98e-djp9.json',
dateField: 'issued_date',
map: (r) => ({
permitNumber: r.permit_number,
permitType: r.permit_type_definition,
description: r.description,
issuedDate: r.issued_date?.slice(0, 10),
address: [r.street_number, r.street_name, r.street_suffix].filter(Boolean).join(' '),
valuation: r.estimated_cost ? Number(r.estimated_cost) : null,
}),
},
};
async function fetchPermits(cityKey, issuedAfter, limit = 100) {
const city = CITIES[cityKey];
const params = new URLSearchParams({
$limit: String(limit),
$order: `${city.dateField} DESC`,
$where: `${city.dateField} >= '${issuedAfter}T00:00:00'`,
});
const rows = await (await fetch(`${city.url}?${params}`)).json();
return rows.map(city.map);
}
console.log(await fetchPermits('chicago', '2026-07-01', 10));
The shortcut: one normalized API for 9 cities
I maintain an Apify actor that does exactly this across nine cities/counties (NYC, Chicago, LA, Austin, SF, New Orleans, Baton Rouge, Montgomery County MD, Norfolk VA), with one shared schema, server-side date filtering and full-text search, and the dataset-ID babysitting handled for you:
One input gets you fresh, high-value leads across cities:
{
"cities": ["austin", "chicago"],
"issuedAfter": "2026-07-01",
"minValuation": 50000,
"maxResultsPerCity": 1000
}
Every record comes back in the same shape — permitNumber, permitType, description, issuedDate, address, valuation, contractorName where the city publishes it, coordinates — and Apify hands you JSON/CSV/Excel export, scheduling (run it every Monday morning), and webhooks into your CRM for free. Pricing is per record returned, so a weekly 1,000-lead pull costs a few dollars.
Bonus: turn permits into qualified contractor lists
Permits tell you who's building. State license records tell you who's licensed — with status, expiration date, and mailing address. Cross-referencing the two is a genuinely underrated play: match permit contractor names against the state's license roll and you get, e.g., "active Florida GCs who pulled zero permits this quarter" (prime targets for lead-gen services) or "contractors whose license expires within 90 days" (renewal/CE marketing).
Florida publishes its entire license database as public CSV extracts, and there's an actor for that too: Florida Contractor & Professional License Search (DBPR). A join on normalized business names between the two datasets is an afternoon of pandas and surprisingly good fun.
Recap
- City open-data portals publish building permits daily through the SODA API — free, official, structured.
- The pain is normalization and dataset churn, not access.
- DIY one city with 30 lines of code; use a maintained multi-city actor when you want breadth and scheduling without the babysitting.
Questions about a specific city's dataset? Drop a comment — I've probably already fought with its column names.
Top comments (0)