API Reference
Building permit data for the United States. Every endpoint below is documented from the live OpenAPI specification, so this page cannot drift from the API it describes.
Authenticate with an X-API-Key header on every request. Keys are
self-serve at permit-stack.com; the free tier needs no
card. The machine-readable spec is at
/public-openapi.json.
31 endpoints · generated from the specification on 6 October 2026.
Permits
Search and retrieve building permits
GET/v1/jurisdictions
List Covered Jurisdictions
Every jurisdiction we cover, with how current and how complete each one is.
`data_through` is the newest permit we actually hold, measured -- not when we last
polled the source. `freshness_label` and `completeness_label` are the same sentences the
export picker and the delivered manifest use, so this can never disagree with them.
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/jurisdictions"
GET/v1/permits/address/{address}
Get Permits By Address
Get all permits for a specific address (partial match).
Parameters
| Name | Type | Description |
|---|---|---|
addressrequired | string | |
page | integer | default: 1 |
per_page | integer | default: 25 |
record_kind | string | 'permit' (default), a specific record_kind, or 'all'. default: "permit" |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitSummary[] | |
hints | Hint[] | |
coverage_confidence | CoverageConfidence | |
tier_window_days | integer | |
tier_window_from | string | |
tier_window_clamped | boolean | |
tier_window_upgrade_url | string | |
locked_fields | string[] | |
locked_upgrade_url | string | |
coverage | JurisdictionCoverage[] | |
total_capped | boolean | |
total_unknown | boolean |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/permits/address/{address}"GET/v1/permits/events
List Permit Events
What CHANGED, instead of re-reading everything: permit transitions
(new_permit, status_change, issued, completed, expired) built from the daily
ingest diff, filterable by city, state, category and jurisdiction.
**This is the endpoint to poll on a schedule, and `cursor` is how to poll it.**
Save the `next_cursor` from each response and pass it back as `cursor` on the
next call; you then receive only what you have not already seen, in the order it
was detected, with no duplicates and no gap. Page until a response comes back
shorter than `per_page`, save that last `next_cursor`, and resume there tomorrow.
**WHERE YOU START MATTERS, AND THE FIRST CALL DOES NOT START AT THE BEGINNING.**
A call with NO cursor returns the NEWEST events and its `next_cursor` is the
frontier -- a watermark at "now". Pass it straight back and you correctly get 0
results, because you have just declared yourself caught up. That is exactly what
a poller wants: from here on you receive everything new. It is NOT a backfill,
and it will not walk history for you -- there are 75,544 events behind that
watermark for the Tampa filter below alone.
To start somewhere else, build the cursor yourself: it is
`<ISO-8601 timestamp>|<event id>`, and `|0` is a valid id floor.
# start polling from now (typical):
GET /v1/permits/events?city=Tampa&state=FL&category=roofing
&event_type=new_permit -> save next_cursor
# ...then, from the next call onward:
GET ...&cursor=2026-09-08T04%3A09%3A16.381111%2B00%3A00%7C51823904
# start from a chosen point instead (backfill the last 30 days, then keep polling):
GET ...&cursor=2026-08-11T00%3A00%3A00%2B00%3A00%7C0
One call answers what a paged re-pull of /v1/permits/search costs hundreds of.
Measured 2026-09-09, that Tampa query returns **38 records in a single call**;
the same question asked by re-pulling search pages 1-100 twelve times a day
costs ~9,600 requests and runs into the daily cap.
`detected_after` still works and is fine for an ad-hoc look at a window, but it
is the wrong tool for a poller: it is INCLUSIVE, and one ingest batch stamps
thousands of events with a single identical `detected_at` (measured 2026-09-10:
3,688 timestamps carry over 1,000 events each, the largest 1,370,561). A poller
that stores the newest `detected_at` and passes it back therefore re-receives
that whole batch every time. `cursor` breaks the tie on the event id and does not.
Supplying a `cursor` returns events oldest-unseen first, which is what lets a
batch be paged safely; without one you get the newest first, for browsing.
`cursor` and `page` are alternatives -- passing both is a 400 rather than a
silently skipped page.
Ingest runs once a day, so a daily poll is enough; `detected_at` is when WE saw
the change, not when the city recorded it. For push instead of poll, register a
webhook (developer tier and above) and skip the polling entirely.
Parameters
| Name | Type | Description |
|---|---|---|
event_type | string | Filter by event type: new_permit, status_change, issued, completed, expired (the permit's expiration date has passed without it being finished; emitted when that date arrives) |
category | string | Permit category (e.g. solar, battery) |
city | string | City name |
state | string | 2-letter state code |
jurisdiction | string | Jurisdiction name (partial) |
permit_id | string | Events for a single permit id |
detected_after | string | Only events detected on/after this UTC timestamp (ISO 8601). Inclusive, so a batch sharing one timestamp is re-delivered; prefer `cursor` for polling. |
cursor | string | Opaque cursor from the previous response's `next_cursor`. Resumes exactly where you stopped, with no duplicates and no gap. Omit on the first call. |
page | integer | default: 1 |
per_page | integer | default: 50 |
limit | integer | Alias of `per_page`. Clamped to 100, not rejected. |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitEventsResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitEventOut[] | |
total_capped | boolean | |
total_unknown | boolean | |
hints | Hint[] | |
next_cursor | string | Pass back as `cursor` to receive only events you have not seen yet, with no duplicates and no gap. Null when this page cannot define a resume point (i.e. a `page` beyond the first, where the newest row on the page is not the newest overall). |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/permits/events"
GET/v1/permits/export
Export Permits
Export permits matching filters as CSV. Tier-gated row limits.
Parameters
| Name | Type | Description |
|---|---|---|
zip_code | string | |
city | string | |
state | string | |
category | string | |
status | PermitStatus | |
property_type | PropertyType | |
tag | string | |
record_kind | string | default: "permit" |
filed_after | string | |
filed_before | string | |
issued_after | string | |
issued_before | string | |
date_after | string | On or after this date, matched against whichever date a record has (issued, else filed). |
date_before | string | On or before this date, matched against whichever date a record has (issued, else filed). |
parcel | string | Parcel number / APN / folio (formatting ignored). |
min_value | number | |
max_value | number | |
min_solar_kw | number | |
max_solar_kw | number | |
min_sqft | number | |
has_enrichment | boolean | |
scope | string | |
q | string | |
contractor_name | string | |
owner_filed | boolean | true = owner-filed permits only (no contractor on record, owner name present — often DIY/homeowner leads, though for feeds without contractor capture the owner may be a builder/institution); false = permits that have a contractor |
jurisdiction | string | A jurisdiction's id (from /v1/jurisdictions) or its name, partial and case-insensitive -- the same matching as /v1/permits/search. Lets a full load be partitioned along the coverage list. |
limit | integer | Rows to return, up to your plan's export maximum (a larger value is refused with 403, never silently lowered). If more rows match than `limit`, the response header X-Permitstack-Truncated is `true`: narrow the filter or split the date range and export again. default: 1000 |
keyword | string | Alias of `q`. |
date_from | string | Alias of `date_after`. |
date_to | string | Alias of `date_before`. |
start_date | string | Alias of `date_after`. |
end_date | string | Alias of `date_before`. |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/permits/export"
GET/v1/permits/search
Search Permits
Search building permits by location, date, category, contractor and more.
Filters combine with AND. Supply at least one narrowing filter -- a city, ZIP,
jurisdiction, date range or lat/lng radius -- for a query that returns quickly.
Results are paginated and ordered newest-first on the permit's best available date
(`date_issued`, falling back to `date_filed`). `total` is exact up to 10,000; beyond
that it stops counting and returns `total: 10000` with `total_capped: true`, meaning
"10,000 or more". If the count cannot finish in time the response carries
`total: null` with `total_unknown: true` -- an unknown total is reported as unknown
and never as a number. Rows are unaffected either way; page through them normally.
`category=hvac` matches HVAC and MECHANICAL together, because AC and furnace work is
filed under either depending on the city. Unknown parameter names are rejected with a
400 rather than ignored, so a typo can never silently return unfiltered results.
Free-tier keys are limited to a recent window; paid tiers have full history.
Parameters
| Name | Type | Description |
|---|---|---|
zip_code | string | 5-digit ZIP code |
city | string | City name |
state | string | 2-letter state code |
jurisdiction | string | Jurisdiction name (e.g. 'Wake County', 'Tacoma') or partial match |
address | string | Street address, partial + case-insensitive (e.g. '1600 Pennsylvania') — indexed, fast |
lat | number | Latitude for radius search |
lng | number | Longitude for radius search |
radius_miles | number | Radius in miles (used with lat/lng) default: 5.0 |
fields | string | 'summary' (default) or 'full'. With 'full' each result is a PermitDetail rather than a PermitSummary -- the same nine extra columns GET /v1/permits/{id} returns (record_kind, date_expired, fee_amount, stories, units, square_footage, applicant_name, contractor_license, created_at) -- so you do not fetch them one permit at a time. See the PermitDetail schema for their types; the declared response schema here is PermitSummary, which is the default shape. Developer plan and above. default: "summary" |
bbox | string | Map-viewport bounding box 'minLng,minLat,maxLng,maxLat'. Returns permits whose location falls inside the box (geocoded permits only). |
polygon | string | A drawn area as a GeoJSON Polygon geometry (URL-encoded), e.g. {"type":"Polygon","coordinates":[[[lng,lat],...]]}. Returns permits inside the polygon (geocoded permits only). |
category | string | Permit category (e.g. solar, SOLAR, roofing, hvac — case insensitive) |
status | PermitStatus | Permit status (e.g. issued, filed, final) |
property_type | PropertyType | Property type (e.g. residential, commercial) |
tag | string | Filter by tag |
record_kind | string | Record kind: 'permit' (default, building permits only), 'contractor', 'tag', 'non_building', 'admin', 'program' (incentive-program records counted through another dataset, e.g. NYSERDA NY-Sun), or 'all' default: "permit" |
filed_after | string | Filed on or after this date |
filed_before | string | Filed on or before this date |
issued_after | string | Issued on or after this date |
issued_before | string | Issued on or before this date |
date_after | string | On or after this date, matched against whichever date a record has (issued, else filed). Use this when a source populates only one of issued/filed — e.g. issued-only feeds vs filed-only feeds. |
date_before | string | On or before this date, matched against whichever date a record has (issued, else filed). |
parcel | string | Parcel number / APN / folio (formatting ignored). Returns permits on that parcel where the source publishes one. |
min_value | number | Minimum estimated value |
max_value | number | Maximum estimated value |
min_solar_kw | number | Minimum extracted solar system size (kW DC) |
max_solar_kw | number | Maximum extracted solar system size (kW DC) |
min_sqft | number | Minimum square footage mentioned (from enrichment) |
has_enrichment | boolean | Only permits that have (true) or lack (false) LLM enrichment |
scope | string | Substring match on the enriched work scope |
q | string | Case-insensitive substring match across description, address, and permit number. Terms under 3 characters are matched but not counted: `total` is null with total_unknown=true (a 1-2 character term has no trigram index and the count would cost tens of seconds). |
contractor_name | string | Contractor name (partial match) |
owner_filed | boolean | true = owner-filed permits only (no contractor on record but an owner name is present — often a DIY/homeowner lead, though for feeds that don't capture contractors the owner may be a builder or institution); false = permits that have a contractor |
has_owner_address | boolean | true = only permits that carry a property-owner mailing address; false = only those that do not. Coverage varies sharply by jurisdiction (we hold the county assessor roll for some and not others), so this is a targeting filter rather than a defect: it returns exactly the rows that are actionable. The address itself is visible on Business and above. |
page | integer | default: 1 |
per_page | integer | Results per page. Above your plan's maximum this is clamped, not rejected; the response echoes the per_page actually applied. default: 25 |
keyword | string | Alias of `q`. |
date_from | string | Alias of `date_after`. |
date_to | string | Alias of `date_before`. |
start_date | string | Alias of `date_after`. |
end_date | string | Alias of `date_before`. |
limit | integer | Alias of `per_page`. Clamped to your plan's maximum, not rejected. |
count_only | boolean | Return only the total for these filters -- no permit records. Skips the row fetch entirely, so it is markedly cheaper for both of us when you are sizing a query rather than reading it. `results` comes back empty and `total_capped` / `total_unknown` mean exactly what they always do. default: false |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitSummary[] | |
hints | Hint[] | |
coverage_confidence | CoverageConfidence | |
tier_window_days | integer | |
tier_window_from | string | |
tier_window_clamped | boolean | |
tier_window_upgrade_url | string | |
locked_fields | string[] | |
locked_upgrade_url | string | |
coverage | JurisdictionCoverage[] | |
total_capped | boolean | |
total_unknown | boolean |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/permits/search"
GET/v1/permits/stats/coverage
Get Coverage Stats
Coverage statistics: total permits, every jurisdiction we hold, and the counties they fall in.
Each jurisdiction carries `data_through` (the newest permit we actually hold, measured),
plus `county` / `county_fips` (the county most of its permits fall in) and `counties`
(the full measured mix with a `share` of the sample per county, since some cities straddle
a county line). `counties` at the top level is the same data keyed by county, listing the
jurisdictions whose permits fall in each one. A null county means not measured, which is
the case for statewide feeds that publish no coordinates.
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/permits/stats/coverage"
GET/v1/permits/sync
Sync Permits
Incremental data feed (change-data-capture). Page the dataset ordered by
(updated_at, id). Omit `cursor` for the initial full load; persist `next_cursor` and pass
it back after each nightly ingest to receive only new + changed permits. Upsert-only
(deletes are not emitted).
Parameters
| Name | Type | Description |
|---|---|---|
cursor | string | Opaque cursor from the previous response's `next_cursor`. Omit to start the initial full sync from the beginning. |
since | string | Alternative start point: an ISO-8601 UTC timestamp; returns permits with updated_at >= since. Ignored when `cursor` is supplied. |
limit | integer | Max permits per page (cursor page size, up to 50,000). default: 5000 |
record_kind | string | 'permit' (default), a specific record_kind, or 'all'. default: "permit" |
state | string | Optional 2-letter state filter to scope the feed. |
category | string | Optional category filter (e.g. solar, roofing). |
fields | string | 'summary' (default) or 'full'. 'full' adds record_kind, date_expired, fee_amount, stories, units, square_footage, applicant_name, contractor_license and created_at to every record, so a mirror does not have to fetch them one permit at a time. OPT-IN: the default payload is unchanged apart from `contractor_id`, added on 2026-09-10 to every permit surface; widening a feed under a consumer with a strict schema is otherwise avoided. default: "summary" |
Responses
| Code | Meaning |
|---|---|
200 | A page of permits in (updated_at, id) order. Keep `next_cursor` and pass it back as `cursor` to resume; `has_more` is false once you are caught up. Each record in `results` has the /v1/permits/search fields (fields=summary) plus the extras listed under `fields=full`. Page size is `limit`, up to 50,000. |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/permits/sync"
GET/v1/permits/{permit_id}
Get Permit
Get full details for a single permit.
Parameters
| Name | Type | Description |
|---|---|---|
permit_idrequired | string |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitDetail
| Field | Type | Description |
|---|---|---|
idrequired | string | Stable PermitStack id. Pass to GET /v1/permits/{id}."f4be62ed-b522-42a3-907b-b4ab4ec1a102" |
permit_numberrequired | string | The permit number as the issuing jurisdiction publishes it. Not unique across jurisdictions, and not always present."R2608-181" |
statusrequired | enum | One of FILED, ISSUED, IN_PROGRESS, FINAL, EXPIRED, CANCELLED, REVOKED, INTERCONNECTED, UNKNOWN -- normalised by us from each source's own vocabulary. UNKNOWN means the source published no status, never that the permit is inactive. INTERCONNECTED comes from the California NEM solar feed. FILED · ISSUED · IN_PROGRESS · FINAL · EXPIRED · CANCELLED · REVOKED · UNKNOWN · INTERCONNECTED "ISSUED" |
categoryrequired | enum | Trade/work classification, UPPERCASE. One of: NEW_CONSTRUCTION, RENOVATION, DEMOLITION, ELECTRICAL, PLUMBING, MECHANICAL, ROOFING, SOLAR, BATTERY, EV_CHARGER, HVAC, FIRE_ALARM, SIGN, FENCE, POOL, FOUNDATION, ADDITION, INTERIOR_REMODEL, GRADING, OTHER. Derived by us from the permit type and description, not published by the city. NOTE when FILTERING: ?category=hvac matches HVAC *and* MECHANICAL, and ?category=mechanical does the same, because HVAC work is routinely filed as MECHANICAL -- they are one market and a bare equality would hide half of it. NEW_CONSTRUCTION · RENOVATION · DEMOLITION · ELECTRICAL · PLUMBING · MECHANICAL · ROOFING · SOLAR · BATTERY · EV_CHARGER · HVAC · FIRE_ALARM · SIGN · FENCE · POOL · FOUNDATION · ADDITION · INTERIOR_REMODEL · GRADING · OTHER "ROOFING" |
tagsrequired | string[] | Free-form keywords extracted from the description (e.g. 'pool', 'reroof'). Additive; do not rely on a fixed vocabulary.["pool"] |
property_typerequired | string | RESIDENTIAL, COMMERCIAL, INDUSTRIAL, MIXED_USE or UNKNOWN. UNKNOWN is common -- many feeds publish nothing that implies a property type."RESIDENTIAL" |
address_streetrequired | string | Street address as published. Stored verbatim, so spacing can be irregular; our address matching normalises whitespace for you."288 Mosaic Blvd" |
address_cityrequired | string | City. Null where the feed publishes none -- notably county-wide feeds. Read null as unknown, never as 'not in a city'."Daytona Beach" |
address_staterequired | string | 2-letter state. Null on 6.6M permits (7.3%) whose feed never populates it; those rows are still returned by ?state= searches, which admit them on independent evidence of the jurisdiction."FL" |
address_ziprequired | string | 5-digit ZIP where published."32124" |
parcel_id | string | Assessor parcel number / APN exactly as the SOURCE publishes it -- formatting varies by county and is not normalised. ~41% populated estate-wide and entirely dependent on whether the source publishes one, so read a null as 'this feed does not carry a parcel', never as 'this property has none'. Filterable via ?parcel=."521804000160" |
description_rawrequired | string | The scope of work exactly as the jurisdiction published it. The single most useful field for lead qualification, and the input our category classifier reads."RE-ROOF: remove existing shingles, install 39 sq architectural shingles" |
estimated_valuerequired | number | Declared job value in USD, as published. Frequently null and occasionally nominal -- treat 0 and 1 as 'not stated'.24500.0 |
date_filedrequired | string | Application/filing date. Null where the source publishes only an issue date."2026-08-14" |
date_issuedrequired | string | Issue date. Null on ~25% of permits estate-wide -- whole feeds publish only a filed date -- which is why date_after/date_before filter on COALESCE(date_issued, date_filed) and issued_after does not."2026-08-21" |
date_completedrequired | string | Completion/final date where the source publishes one. Usually null. |
approval_days | integer | Calendar days from date_filed to date_issued (null unless both dates present) |
construction_days | integer | Calendar days from date_issued to date_completed (null unless both dates present) |
contractor_name | string | Contractor of record, cleaned and de-duplicated by us. Null where the feed publishes no contractor -- which is whole jurisdictions, not scattered rows: 45% of permits estate-wide carry one. A page of nulls usually means the source does not publish contractors, not that our data is missing; the `jurisdiction_coverage` block on a search response tells you which."GLZ Industries LLC" |
contractor_id | string | Opaque id of the contractor on this permit. Pass it to GET /v1/contractors/{id} for the full record; null when the source publishes no contractor for this permit. |
owner_name | string | Owner of record. contractor_name null + owner_name set means no contractor was recorded -- often a homeowner who pulled the permit themselves, which is a sales lead. Caveat: on feeds that capture no contractor at all, EVERY permit looks owner-filed and the owner may be a builder, not a homeowner. Use the ?owner_filed= filter rather than inferring this yourself."SMITH JOHN A" |
owner_address | string | Property-owner MAILING address. Business plan ($149/mo) and above; null on every other plan, which is a gate and not an absence of data. Joined from county assessor rolls by parcel, so coverage is bimodal -- near-complete in the jurisdictions whose roll we have loaded, absent in those we have not. Never read a null as 'this property has no owner on record'. |
jurisdiction_name | string | The PermitStack data source this permit came from -- a city, county or statewide feed, which is not always the permit's own city."Daytona Beach" |
latitude | number | WGS84 latitude. Null where the source publishes no coordinates and we could not geocode it; such permits are invisible to radius, bbox and polygon search.29.1872 |
longitude | number | WGS84 longitude.-81.0559 |
location_source | string | Where the coordinates came from. 'source' = published by the city with the permit record, as good as the city's own data. 'geocoded' = derived by us from the site address (TIGER, address-centroid quality), which for a structure set back from the road, such as an antenna mast, marks the property address rather than the structure. 'derived' = set by us by another method (typically a parcel-centroid join), so treat it as ours, not the city's. 'source' is judged per feed: in a feed that publishes coordinates, a minority of rows whose record lacked them may have been filled by our geocoder before 2026-09-21, when it began logging successes, and those read 'source'. Null when latitude/longitude are null."source" |
enrichment | PermitEnrichment | Structured detail parsed from the description (e.g. solar kW) where we could extract it. Null for most permits. |
record_kind | string | permit | contractor | tag | non_building | admin. Some portals mix non-permit records (contractor registrations, right-of-way, administrative) into the same feed; we store them and label them rather than counting them as permits. Search and export return record_kind='permit' only unless you ask otherwise."permit" |
date_expiredrequired | string | Expiry date where published. Deliberately NOT null-checked against the future the way the other dates are -- an expiry legitimately lies ahead."2027-02-21" |
fee_amount | number | Permit/inspection fee. Frequently null — most open-data feeds do not publish fee data. |
storiesrequired | integer | Building stories, where published.1 |
unitsrequired | integer | Dwelling/tenant units, where published.1 |
square_footagerequired | number | Project square footage, where published. Usually null.2100.0 |
applicant_namerequired | string | Whoever filed the application. On most feeds this is the OWNER; on a few it is the contractor, and for those tenants we map it to contractor_name instead. Never assume which one it is from this field alone."WINDOW WORLD OF DAYTONA" |
contractor_license | string | Contractor's licence number as published or as matched against a state licence-board roster. A free public identifier; contact details remain gated to /v1/contractors/{id} on Developer+."CBC1262595" |
created_at | string | When PermitStack first ingested this record (ISO 8601). NOT a date the city published -- use date_filed/date_issued for that."2026-08-22T04:11:03.882174+00:00" |
PermitEnrichment
| Field | Type | Description |
|---|---|---|
scope | string | |
work_summary | string | |
solar_kw | number | |
sqft | number | |
units | integer | |
is_residential | boolean | |
is_commercial | boolean | |
materials | string[] |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/permits/{permit_id}"GET/v1/plays
List Plays
List available trade plays and their parameters.
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/plays"
GET/v1/plays/battery-retrofit-candidates
Battery Retrofit Candidates
Solar permits aged between min/max years whose address has NO battery permit
on file — the storage-retrofit lead list. Requires a location filter.
Parameters
| Name | Type | Description |
|---|---|---|
state | string | 2-letter state code |
city | string | City name |
zip_code | string | 5-digit ZIP |
jurisdiction | string | Jurisdiction name (partial) |
min_age_years | number | Minimum age of the PV permit, in years default: 2 |
max_age_years | number | Maximum age of the PV permit, in years default: 7 |
page | integer | default: 1 |
per_page | integer | default: 25 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitSummary[] | |
hints | Hint[] | |
coverage_confidence | CoverageConfidence | |
tier_window_days | integer | |
tier_window_from | string | |
tier_window_clamped | boolean | |
tier_window_upgrade_url | string | |
locked_fields | string[] | |
locked_upgrade_url | string | |
coverage | JurisdictionCoverage[] | |
total_capped | boolean | |
total_unknown | boolean |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/plays/battery-retrofit-candidates"
GET/v1/plays/orphan-recovery
Orphan Recovery
PV permits whose installer (contractor, applicant, or owner) matches `installer`
in a territory — a named competitor's customer base. Requires a location filter.
Parameters
| Name | Type | Description |
|---|---|---|
installerrequired | string | Installer name to match (contractor/applicant/owner) |
state | string | 2-letter state code |
city | string | City name |
zip_code | string | 5-digit ZIP |
jurisdiction | string | Jurisdiction name (partial) |
page | integer | default: 1 |
per_page | integer | default: 25 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitSummary[] | |
hints | Hint[] | |
coverage_confidence | CoverageConfidence | |
tier_window_days | integer | |
tier_window_from | string | |
tier_window_clamped | boolean | |
tier_window_upgrade_url | string | |
locked_fields | string[] | |
locked_upgrade_url | string | |
coverage | JurisdictionCoverage[] | |
total_capped | boolean | |
total_unknown | boolean |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/plays/orphan-recovery"
GET/v1/plays/reroof-due
Reroof Due
Roofing permits aged min-max years whose address has NO newer roofing permit —
homes whose roof is statistically due for replacement. Alias of
/v1/plays/system-age?trade=roofing (kept as the discoverable roofing-first name).
Parameters
| Name | Type | Description |
|---|---|---|
state | string | 2-letter state code |
city | string | City name |
zip_code | string | 5-digit ZIP |
jurisdiction | string | Jurisdiction name (partial) |
min_age_years | number | Minimum age of the roofing permit, in years default: 12 |
max_age_years | number | Maximum age of the roofing permit, in years default: 25 |
page | integer | default: 1 |
per_page | integer | default: 25 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitSummary[] | |
hints | Hint[] | |
coverage_confidence | CoverageConfidence | |
tier_window_days | integer | |
tier_window_from | string | |
tier_window_clamped | boolean | |
tier_window_upgrade_url | string | |
locked_fields | string[] | |
locked_upgrade_url | string | |
coverage | JurisdictionCoverage[] | |
total_capped | boolean | |
total_unknown | boolean |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/plays/reroof-due"
GET/v1/plays/system-age
System Age
Addresses whose LATEST permit in the trade is min–max years old with nothing
since — the system is statistically due for replacement. Only trades with a real
lifecycle are supported (roofing, hvac, mechanical, solar=repower, pool).
Parameters
| Name | Type | Description |
|---|---|---|
traderequired | string | roofing | hvac | mechanical | solar | pool |
state | string | 2-letter state code |
city | string | City name |
zip_code | string | 5-digit ZIP |
jurisdiction | string | Jurisdiction name (partial) |
min_age_years | number | Override the trade's default minimum age |
max_age_years | number | Override the trade's default maximum age |
page | integer | default: 1 |
per_page | integer | default: 25 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PermitSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | PermitSummary[] | |
hints | Hint[] | |
coverage_confidence | CoverageConfidence | |
tier_window_days | integer | |
tier_window_from | string | |
tier_window_clamped | boolean | |
tier_window_upgrade_url | string | |
locked_fields | string[] | |
locked_upgrade_url | string | |
coverage | JurisdictionCoverage[] | |
total_capped | boolean | |
total_unknown | boolean |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/plays/system-age"
Property History
Get permit history for a specific address
GET/v1/property/by-parcel
Get Property By Parcel
Complete construction history + property profile for a parcel / APN.
Looks up every permit whose source parcel number matches `parcel`, with
formatting ignored, so an APN from a county appraiser matches regardless of how
the permit feed punctuates it. Pass `state` to disambiguate the same parcel
number used by different counties. Returns the same profile shape as /history
(timeline, category breakdown, contractors, and roof/solar/HVAC age signals).
Parcel coverage spans the county / appraiser / GIS sources that publish a parcel
id; a handful of Accela-sourced jurisdictions omit parcel in their public export
and won't match here (use /property/history by address for those).
A parcel with **no** permits returns `found: false` (HTTP 200), not an error.
Parameters
| Name | Type | Description |
|---|---|---|
parcelrequired | string | Parcel number / APN / folio. Formatting is ignored — dashes, dots and spaces are stripped before matching. |
state | string | Optional 2-letter state to disambiguate the same parcel number across counties |
page | integer | default: 1 |
per_page | integer | default: 50 |
limit | integer | Alias of per_page. |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PropertyHistoryResponse
| Field | Type | Description |
|---|---|---|
queryrequired | PropertyQuery | |
foundrequired | boolean | |
total_matchesrequired | integer | |
truncatedrequired | boolean | |
distinct_addresses | integer | |
distinct_jurisdictions | integer | |
fan_out_warning | string | |
summaryrequired | PropertySummary | |
coverage | CoverageConfidence | |
data_currency | JurisdictionCoverage[] | |
pagerequired | integer | |
per_pagerequired | integer | |
has_morerequired | boolean | |
permitsrequired | PermitSummary[] |
PropertyQuery
| Field | Type | Description |
|---|---|---|
address | string | |
parcel | string | |
city | string | |
state | string | |
zip | string |
PropertySummary
| Field | Type | Description |
|---|---|---|
total_permitsrequired | integer | |
first_permit_date | string | |
last_permit_date | string | |
years_of_history | integer | |
total_estimated_value | number | |
categoriesrequired | object | |
jurisdictionsrequired | string[] | |
contractorsrequired | string[] | |
signalsrequired | PropertySignals |
PropertySignals
| Field | Type | Description |
|---|---|---|
has_solar | boolean | |
solar_kw | number | |
last_solar_date | string | |
has_battery | boolean | |
last_battery_date | string | |
has_roofing | boolean | |
last_roofing_date | string | |
has_pool | boolean | |
last_pool_date | string | |
has_addition | boolean | |
has_new_construction | boolean | |
has_electrical | boolean | |
has_hvac | boolean | |
last_hvac_date | string | |
has_plumbing | boolean | |
has_demolition | boolean | |
last_activity_date | string |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/property/by-parcel"
GET/v1/property/history
Get Property History
Complete construction history + property profile for an address.
Matches every permit whose street address contains the supplied address;
pass `city`/`state`/`zip` to disambiguate the same street number across
metros. Returns a derived profile (permit timeline, category breakdown,
contractors, and underwriting signals such as solar/roof/pool age) plus
the paginated permit records.
An address with **no** permits returns `found: false` with an empty
profile (HTTP 200) — a clean property is a valid, useful answer, not an
error.
Parameters
| Name | Type | Description |
|---|---|---|
addressrequired | string | Street address to look up (e.g. '123 Main St') |
city | string | Optional city to disambiguate identical street addresses |
state | string | Optional 2-letter state code |
zip | string | Optional ZIP (prefix-matched) |
page | integer | default: 1 |
per_page | integer | default: 50 |
limit | integer | Alias of per_page. |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
PropertyHistoryResponse
| Field | Type | Description |
|---|---|---|
queryrequired | PropertyQuery | |
foundrequired | boolean | |
total_matchesrequired | integer | |
truncatedrequired | boolean | |
distinct_addresses | integer | |
distinct_jurisdictions | integer | |
fan_out_warning | string | |
summaryrequired | PropertySummary | |
coverage | CoverageConfidence | |
data_currency | JurisdictionCoverage[] | |
pagerequired | integer | |
per_pagerequired | integer | |
has_morerequired | boolean | |
permitsrequired | PermitSummary[] |
PropertyQuery
| Field | Type | Description |
|---|---|---|
address | string | |
parcel | string | |
city | string | |
state | string | |
zip | string |
PropertySummary
| Field | Type | Description |
|---|---|---|
total_permitsrequired | integer | |
first_permit_date | string | |
last_permit_date | string | |
years_of_history | integer | |
total_estimated_value | number | |
categoriesrequired | object | |
jurisdictionsrequired | string[] | |
contractorsrequired | string[] | |
signalsrequired | PropertySignals |
PropertySignals
| Field | Type | Description |
|---|---|---|
has_solar | boolean | |
solar_kw | number | |
last_solar_date | string | |
has_battery | boolean | |
last_battery_date | string | |
has_roofing | boolean | |
last_roofing_date | string | |
has_pool | boolean | |
last_pool_date | string | |
has_addition | boolean | |
has_new_construction | boolean | |
has_electrical | boolean | |
has_hvac | boolean | |
last_hvac_date | string | |
has_plumbing | boolean | |
has_demolition | boolean | |
last_activity_date | string |
CoverageConfidence
| Field | Type | Description |
|---|---|---|
confidencerequired | string | |
coveredrequired | boolean | |
jurisdiction | string | |
data_status | string | |
data_through | string | |
freshness | string | |
jurisdiction_permit_count | integer | |
noterequired | string | |
tier_window_days | integer | |
tier_window_from | string |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/property/history"
Contractors
Search contractors and see their permit history
GET/v1/contractors/search
Search Contractors
Search contractors by name, location, or specialty, ranked by activity score.
Parameters
| Name | Type | Description |
|---|---|---|
name | string | Contractor name (partial match) |
state | string | 2-letter state code |
city | string | City name |
specialty | string | Specialty tag (e.g. solar, roofing, hvac) |
license_number | string | Exact state licence number, e.g. 'CBC1262595'. Matched exactly, and also tried uppercased -- so any capitalisation works for the licences stored uppercase, which is 99.96% of them. Combine with license_state when the same number is issued in more than one state. |
license_state | string | 2-letter state that ISSUED the licence. Not the same as `state`, which is where the contractor pulls permits. |
min_permits | integer | Minimum total permits |
min_score | integer | Minimum contractor activity score (0-100) |
sort | string | Sort order: 'score' (default), 'permits', or 'recent' default: "score" |
page | integer | default: 1 |
per_page | integer | default: 25 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
ContractorSearchResponse
| Field | Type | Description |
|---|---|---|
totalrequired | integer | |
pagerequired | integer | |
per_pagerequired | integer | |
resultsrequired | ContractorSummary[] | |
specialties_matched | string[] |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/contractors/search"
GET/v1/contractors/{contractor_id}
Get Contractor
Get a contractor's full profile with permit stats.
`phone` and `email` are contractor contact fields gated to the Developer plan and up
(see /v1/billing/plans for current pricing); on free/indie/hobbyist they return null.
`name`, `license_number`, `specialties`, `city`/`state`/`address`, and the stats are
available on all plans.
Parameters
| Name | Type | Description |
|---|---|---|
contractor_idrequired | string |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
ContractorProfile
| Field | Type | Description |
|---|---|---|
idrequired | string | |
namerequired | string | |
license_numberrequired | string | |
license_staterequired | string | |
cityrequired | string | |
staterequired | string | |
total_permitsrequired | integer | |
first_permit_daterequired | string | |
last_permit_daterequired | string | |
specialtiesrequired | string[] | |
score | integer | |
is_business | boolean | |
phonerequired | string | |
emailrequired | string | |
contact_locked | boolean | |
locked_fields | string[] | |
upgrade_url | string | |
addressrequired | string | |
zip_coderequired | string | |
recent_categories | string[] | |
avg_project_value | number |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/contractors/{contractor_id}"GET/v1/contractors/{contractor_id}/permits
Get Contractor Permits
Get all permits associated with a contractor.
Parameters
| Name | Type | Description |
|---|---|---|
contractor_idrequired | string | |
page | integer | default: 1 |
per_page | integer | default: 25 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/contractors/{contractor_id}/permits"Metrics
Pre-aggregated permit counts and valuation from a nightly rollup. Use these instead of paging through search results to count them: they answer 'how many' and 'is it trending' in one millisecond-scale call. Developer plan and above.
GET/v1/metrics/cities
Metrics Cities
Busiest cities in a state — the ranking customers were building by hand.
Available on the Developer plan and above (see /v1/billing/plans for current pricing).
Parameters
| Name | Type | Description |
|---|---|---|
staterequired | string | |
category | string | |
months | integer | default: 12 |
limit | integer | default: 50 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/metrics/cities"
GET/v1/metrics/current
Metrics Current
Category breakdown over a trailing window — the 'what is happening here now' view.
Available on the Developer plan and above (see /v1/billing/plans for current pricing).
Parameters
| Name | Type | Description |
|---|---|---|
state | string | |
city | string | |
days | integer | Trailing window. Rounded to whole months by the rollup. default: 90 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/metrics/current"
GET/v1/metrics/monthly
Metrics Monthly
Monthly permit counts and total valuation for a city/state/category.
Available on the Developer plan and above (see /v1/billing/plans for current pricing).
Served from a nightly rollup, so it answers in milliseconds where the equivalent
search-and-count takes seconds. At least one of `state` or `city` is required: an
unfiltered national series would be a different (and much larger) product.
Parameters
| Name | Type | Description |
|---|---|---|
state | string | Two-letter state code, e.g. FL |
city | string | City name; case-insensitive |
category | string | Permit category, e.g. ROOFING |
months | integer | How many trailing months to return default: 24 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/metrics/monthly"
Webhooks
New and changed permits pushed to your endpoint about 60 seconds after we ingest them; most sources are ingested nightly. Developer plan ($79/mo) and above.
GET/v1/webhooks/
List Webhooks
List all your registered webhooks.
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/webhooks/"
POST/v1/webhooks/
Create Webhook
Register a webhook to be notified when new permits match your filters.
Available on the Developer plan and above (see /v1/billing/plans for current
pricing). Maximum 10 webhooks per API key.
When a new permit matches your filters, we'll POST the permit data as JSON to your URL.
Set contractor_name to track a specific company — you'll get a POST within minutes
of any permit they pull appearing in our data (competitor tracking).
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
403 | Your plan does not include this endpoint, or an option you passed. The body is machine-readable: `error` is `feature_locked`, `feature` names the gate, `upgrade_url` links to the cheapest plan that unlocks it, and `current_tier` is the plan you are on. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/v1/webhooks/"
DELETE/v1/webhooks/{webhook_id}
Delete Webhook
Delete a webhook.
Parameters
| Name | Type | Description |
|---|---|---|
webhook_idrequired | string |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/webhooks/{webhook_id}"PATCH/v1/webhooks/{webhook_id}
Update Webhook
Re-activate (or deactivate) one of your webhooks.
Re-activating resumes delivery from now: permits ingested while the webhook was paused
are not replayed (use /v1/permits/search or /v1/permits/sync to backfill a gap), so a
newly fixed endpoint is not hit with the whole backlog at once. The failure count resets
to zero.
Parameters
| Name | Type | Description |
|---|---|---|
webhook_idrequired | string | |
is_activerequired | boolean | true re-activates a webhook the failure breaker switched off. |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/webhooks/{webhook_id}"GET/v1/webhooks/{webhook_id}/deliveries
Get Webhook Deliveries
Why your webhook is or is not being delivered, seen from our side of the connection.
Only FAILED attempts are recorded individually; successful deliveries are counted
(fire_count, last_fired_at). An empty `recent_failures` list is therefore good news, not
missing data. `consecutive_failures` is what the automatic pause acts on: when it reaches
the limit the webhook is deactivated, so it is the field to watch.
Parameters
| Name | Type | Description |
|---|---|---|
webhook_idrequired | string | |
limit | integer | How many recent failed attempts to return. default: 20 |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/webhooks/{webhook_id}/deliveries"POST/v1/webhooks/{webhook_id}/rotate-secret
Rotate Webhook Secret
Issue a NEW signing secret for this webhook. The old one stops working immediately.
Every delivery after this call is signed with the new secret, so update your verifier
first. Creating a webhook again with the same settings returns the existing secret; this
endpoint is the only way to rotate it.
Parameters
| Name | Type | Description |
|---|---|---|
webhook_idrequired | string |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/webhooks/{webhook_id}/rotate-secret"GET/v1/webhooks/{webhook_id}/secret
Get Webhook Secret
Retrieve your webhook signing secret. Use this to validate signatures.
The X-PermitStack-Signature header on incoming webhook requests is the
HMAC-SHA256 of the request body using this secret. Verify before
processing to ensure the request came from PermitStack.
Parameters
| Name | Type | Description |
|---|---|---|
webhook_idrequired | string |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/webhooks/{webhook_id}/secret"POST/v1/webhooks/{webhook_id}/test
Test Webhook
Send a test event to a webhook URL.
Useful for verifying your webhook endpoint is reachable and signature
validation works. Sends a fake permit payload with event=permit.test.
Parameters
| Name | Type | Description |
|---|---|---|
webhook_idrequired | string |
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
401 | Missing or invalid API key. Pass a key as the `X-API-Key` header. |
422 | Validation Error |
429 | Rate limit exceeded -- either the per-minute burst or the daily cap for your plan. Retry after the window resets; the daily cap resets at UTC midnight. |
Example
curl -H "X-API-Key: pk_your_key_here" \
"https://api.permit-stack.com/v1/webhooks/{webhook_id}/test"Health
Service health checks
GET/health
Health Check
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/health"
GET/stats
Public Stats
Public stats endpoint — no auth required.
Returns aggregate coverage numbers for marketing/transparency.
Cached server-side for 30 minutes.
Responses
| Code | Meaning |
|---|---|
200 | Successful Response |
Example
curl -H "X-API-Key: pk_your_key_here" \ "https://api.permit-stack.com/stats"