Permits (listing endpoint)
curl --request GET \
--url https://api.permitcore.io/v1/permitsimport requests
url = "https://api.permitcore.io/v1/permits"
response = requests.get(url)
print(response.text)const options = {method: 'GET'};
fetch('https://api.permitcore.io/v1/permits', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.permitcore.io/v1/permits",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.permitcore.io/v1/permits"
req, _ := http.NewRequest("GET", url, nil)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.permitcore.io/v1/permits")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.permitcore.io/v1/permits")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
response = http.request(request)
puts response.read_bodyAPI Reference
Permits (listing endpoint)
Paginate permit rows by jurisdiction × date with the full normalized + source + enrichment fields.
GET
/
v1
/
permits
Permits (listing endpoint)
curl --request GET \
--url https://api.permitcore.io/v1/permitsimport requests
url = "https://api.permitcore.io/v1/permits"
response = requests.get(url)
print(response.text)const options = {method: 'GET'};
fetch('https://api.permitcore.io/v1/permits', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.permitcore.io/v1/permits",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"net/http"
"io"
)
func main() {
url := "https://api.permitcore.io/v1/permits"
req, _ := http.NewRequest("GET", url, nil)
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.get("https://api.permitcore.io/v1/permits")
.asString();require 'uri'
require 'net/http'
url = URI("https://api.permitcore.io/v1/permits")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Get.new(url)
response = http.request(request)
puts response.read_bodyReturns paginated permit rows filtered by jurisdiction + date range.
The response example below is generated from the live OpenAPI spec
at api.permitcore.io/openapi.json —
re-pulled before each docs deploy, never hand-written.
The
List-vs-detail asymmetry: the nested
The nested
See Get a permit by canonical_uid.
Note that the top-level PLUTO-derived fields (The
Authentication
Authorization: Bearer <your-api-key> — see Authentication.
Query parameters
These are the parameters/v1/permits actually accepts, per the live
OpenAPI spec. There is no cohort parameter on /v1/permits — buyer-segment
filtering lives on the /v1/permits/target
variant via target_segment.
| Param | Type | Notes |
|---|---|---|
jurisdiction | string (slug) | Comma-separated jurisdiction slug(s), e.g. nyc, la. |
issued_after | ISO 8601 date | Lower bound for issued_date (YYYY-MM-DD). |
issued_before | ISO 8601 date | Upper bound for issued_date. |
permit_type | string | Filter by normalized permit type. |
work_class | string | Filter by work class. |
bbox | string | minLon,minLat,maxLon,maxLat bounding box. |
min_valuation | integer | Minimum valuation_usd. |
has_extracted | boolean | Only rows with LLM-extracted enrichment. |
updated_since | ISO 8601 date-time | Rows changed since a timestamp (delta sync). |
contractor_canonical_id | integer | Filter to one canonical contractor. |
parcel_key | string | Filter to one parcel. |
q | string | Free-text search (contractor / owner). Min 3 chars. |
order_by | enum | issued_date (default) · applied_date · last_seen_at · valuation_usd. |
order_dir | enum | asc · desc. |
page | integer | 1-indexed page number (offset pagination). |
per_page | integer | Page size; 1–500. |
cursor | string | Opaque keyset cursor for deep paging — see Pagination. When supplied, page is ignored. |
Response shape
The example below is generated from thePermitResponse schema in
openapi.json. The enrichment fields (cohort, dq_flags, top-level
PLUTO-derived fields, description_llm_enriched) are now on the wire as
of the 2026-06-04 promotion. The nested pluto DQ-status object is
list-endpoint NULL by design — see the list-vs-detail asymmetry note below.
{
"items": [
{
"id": 4218034,
"jurisdiction_slug": "nyc",
"source_permit_id": "BT-202601090",
"canonical_uid": "1:BT-202601090",
"filing_id": null,
"address_full": "82 ASP 1ST FLOOR, Manhattan, NY 10009",
"address_street": "82 ASP",
"address_city": "Manhattan",
"address_state": "NY",
"address_zip": "10009",
"parcel_apn": "1004920019",
"lat": 40.7271,
"lng": -73.9836,
"geocode_confidence": 0.92,
"geocode_source": "city_geocoder_v2",
"permit_type_raw": "BUILDING/EW",
"permit_type_normalized": "alteration",
"work_class": "alteration_minor",
"building_subtype": "tenant_improvement",
"is_new_construction": false,
"is_alteration": true,
"is_demolition": false,
"units_residential": null,
"units_commercial": null,
"units_total": null,
"square_footage_total": null,
"valuation_usd": 18400,
"year_built": 1925,
"building_class": "O4",
"zoning_district": "C6-2",
"lot_area": 2540,
"applied_date": "2026-05-20",
"issued_date": "2026-05-27",
"finaled_date": null,
"expired_date": null,
"current_status": "issued",
"status_raw": "ISSUED-PARTIAL",
"description_raw": "Interior reno @ unit 3R; partition + finishes; no MEP scope",
"description_extracted": null,
"description_llm_enriched": null,
"applicant_name": null,
"applicant_role": null,
"contractor_name": "MIDTOWN BUILD CO INC",
"contractor_license": "GC-0148921",
"architect_name": null,
"engineer_name": null,
"owner_name": null,
"source_url": "https://www.nyc.gov/site/buildings/permit/BT-202601090",
"first_seen_at": "2026-05-27T03:14:22Z",
"last_seen_at": "2026-06-04T03:11:08Z",
"last_changed_at": "2026-05-27T03:14:22Z",
"cohort": "commercial_alteration",
"dq_flags": {
"issued_date_is_parse_bug": false,
"issued_date_is_placeholder": false,
"issued_date_is_future_dated": false,
"valuation_usd_is_placeholder": false,
"valuation_usd_is_oob": false,
"units_residential_is_placeholder": false,
"units_total_is_placeholder": false,
"units_residential_is_oob": false,
"units_total_is_oob": false,
"units_commercial_is_placeholder": false,
"geocode_is_sentinel": false,
"geocode_is_oob": false
},
"pluto": null
}
],
"pagination": {
"total_count": 1000,
"next_page": 2,
"has_more": true
}
}
pagination object above is abbreviated for readability. See
Pagination for the full, canonical envelope both this endpoint
and /v1/permits/target return.
Pagination
/v1/permits and /v1/permits/target return the same response envelope:
{
"items": [ /* … */ ],
"pagination": {
"page": 1,
"per_page": 25,
"total_pages": 40,
"total_count": 1000,
"total_count_is_estimate": true,
"has_more": true,
"next_page": 2,
"prev_page": null,
"next_cursor": "eyJpc3N1ZWRfZGF0ZSI6…"
},
"filters_applied": { "jurisdiction": "nyc" },
"query_time_ms": 34
}
total_countis capped at 1,000 on selective-filter queries, and jurisdiction-only queries use a fast row estimate. Whentotal_count_is_estimateorhas_moreistrue, the true total exceedstotal_count— render it as"1,000+"if you surface it directly.- Offset paging (both endpoints): pass
page+per_page. - Keyset / cursor paging (
/v1/permitsonly): pass the prior response’spagination.next_cursorback as thecursorquery parameter to page deep or across jurisdictions without anOFFSETscan. Whencursoris supplied,pageis ignored; the cursor is bound to the request’sorder_by/order_dir, so keep those stable while paging.next_cursorisnullon the last page./v1/permits/targetis offset-only (page/per_page).
What’s on the wire
The list-endpoint shape carries 58 fields organized as:- Identity —
id,jurisdiction_slug,source_permit_id,canonical_uid,filing_id - Address —
address_full,address_street,address_city,address_state,address_zip,parcel_apn - Geocode metadata —
lat,lng,geocode_confidence,geocode_source - Type + classification —
permit_type_raw,permit_type_normalized,work_class,building_subtype,is_new_construction,is_alteration,is_demolition - Scope —
units_residential,units_commercial,units_total,square_footage_total,valuation_usd - PLUTO-derived (denormalized) —
year_built,building_class,zoning_district,lot_area - Dates —
applied_date,issued_date,finaled_date,expired_date - Status + description —
current_status,status_raw,description_raw,description_extracted,description_llm_enriched - People —
applicant_name,applicant_role,contractor_name,contractor_license,architect_name,engineer_name,owner_name - Source link + timestamps —
source_url,first_seen_at,last_seen_at,last_changed_at - Enrichment —
cohort,dq_flags,pluto
Enrichment field semantics
cohort— the buyer-targeting bucket the row classifies into, one of the 23 canonical segments.null= row not in the cohort matview OR in the matview but unsegmented. Identical taxonomy totarget_segmenton/v1/permits/target. See Concepts → Cohorts.dq_flags— 12-key boolean object from the bounds-rule registry. All 12 keys are always present when this field is non-null. Semantic note:falsemeans “the rule has not flagged this value,” NOT “the value was independently verified clean.” NULL silver flag values are coerced tofalseper the SD-039 rule. See Data freshness → field-level DQ flags for the full flag set + semantics.description_llm_enriched— LLM-extracted feature object (e.g.scope_summary,unit_count,square_footage,tenant_improvement_flag). Distinct column fromdescription_extracted(which is adapter-populated, per-jurisdiction shape). Coverage today: Austin + Chicagocommercial_newpermits only (~17,861 prod rows). Broader cross-metro LLM extraction is on the roadmap.
List-vs-detail asymmetry: the nested pluto object
The nested pluto object (PLUTO parcel-data presence + per-flag DQ
status — {is_placeholder, oob}) is ALWAYS null on /v1/permits
by design.
The PLUTO LEFT JOIN required to populate the DQ-status object would
add ~75ms p95 to the list query, which disqualifies it for the browse
path. To get the populated pluto object, query the per-permit detail
endpoint:
GET /v1/permits/by-canonical-uid/{canonical_uid}
year_built,
building_class, zoning_district, lot_area, units_total) are
denormalized into the silver layer and DO populate on this endpoint
when known. Only the nested pluto DQ-status object is detail-only.
The /v1/permits/target variant
/v1/permits/target returns the same response envelope but exposes a curated
commercial/multifamily/ADU slice, filtered by target_segment=<segment>. This is
the cohort-classified slice that the site’s recency surfaces query. It also adds
geo filters (state, metro_cbsa), value bounds (value_min/value_max), and
enrichment filters (climate_zone, roof_area_min, hvac_tonnage_min). It is
offset-only (no cursor).
target_segment accepts a comma-separated list of the 23 canonical segment
slugs (see Cohorts). Behavior:
- Valid segment with zero rows in window:
200 OKwith an emptyitemsarray — no404. Usepagination.total_countto confirm the empty result. - Invalid slug (not in the 23-segment enum):
422 Unprocessable Entity(invalid-target-segment) with a problem+json body enumerating the accepted whitelist. - Newer cohort caveat:
multifamily_mepis filterable and populated but not yet certified to the ≥99% precision bar of the other 22 segments — currently 3 metros (primarily Austin), largely historical. See the note on Cohorts. The other 22 are the established, certified set.
/v1/permits/target also has a separate cohort parameter — but it is a
per-manufacturer cohort (an A5 YAML basename such as roofing-manufacturer),
not a buyer segment. An unknown value returns 400
(unknown-cohort). Use target_segment for segment
filtering; use cohort only for manufacturer-cohort targeting.Quotas
Quotas are tier-based and apply to every request against this endpoint. See Rate limits for the matrix + theX-RateLimit-* response headers + the 429 problem+json shape.
Example request
curl "https://api.permitcore.io/v1/permits?jurisdiction=nyc&issued_after=2026-05-01&per_page=25" \
-H "Authorization: Bearer $PERMITCORE_API_KEY" \
-H "Accept: application/json"
Related
- Get a permit by canonical_uid — the per-permit detail endpoint with full enrichment + nested
pluto. - Cohort distribution — aggregate counts behind these rows.
- Free sandbox — client-side DuckDB-WASM queries against the last 10 days of permits across the live metros. No signup, no API key.