Skip to content
Products API & Data Search Coverage Property History Alerts For Contractors Data Exports
Solutions Solar & Energy Roofing HVAC Real Estate Insurance Lenders & CRE Contractors
Pricing Refer & Earn API Docs Sign In Get Started

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

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
429Rate 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

NameTypeDescription
addressrequiredstring
pageinteger
default: 1
per_pageinteger
default: 25
record_kindstring'permit' (default), a specific record_kind, or 'all'.
default: "permit"

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitSummary[]
hintsHint[]
coverage_confidenceCoverageConfidence
tier_window_daysinteger
tier_window_fromstring
tier_window_clampedboolean
tier_window_upgrade_urlstring
locked_fieldsstring[]
locked_upgrade_urlstring
coverageJurisdictionCoverage[]
total_cappedboolean
total_unknownboolean

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
event_typestringFilter 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)
categorystringPermit category (e.g. solar, battery)
citystringCity name
statestring2-letter state code
jurisdictionstringJurisdiction name (partial)
permit_idstringEvents for a single permit id
detected_afterstringOnly events detected on/after this UTC timestamp (ISO 8601). Inclusive, so a batch sharing one timestamp is re-delivered; prefer `cursor` for polling.
cursorstringOpaque cursor from the previous response's `next_cursor`. Resumes exactly where you stopped, with no duplicates and no gap. Omit on the first call.
pageinteger
default: 1
per_pageinteger
default: 50
limitintegerAlias of `per_page`. Clamped to 100, not rejected.

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitEventOut[]
total_cappedboolean
total_unknownboolean
hintsHint[]
next_cursorstringPass 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

NameTypeDescription
zip_codestring
citystring
statestring
categorystring
statusPermitStatus
property_typePropertyType
tagstring
record_kindstring
default: "permit"
filed_afterstring
filed_beforestring
issued_afterstring
issued_beforestring
date_afterstringOn or after this date, matched against whichever date a record has (issued, else filed).
date_beforestringOn or before this date, matched against whichever date a record has (issued, else filed).
parcelstringParcel number / APN / folio (formatting ignored).
min_valuenumber
max_valuenumber
min_solar_kwnumber
max_solar_kwnumber
min_sqftnumber
has_enrichmentboolean
scopestring
qstring
contractor_namestring
owner_filedbooleantrue = 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
jurisdictionstringA 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.
limitintegerRows 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
keywordstringAlias of `q`.
date_fromstringAlias of `date_after`.
date_tostringAlias of `date_before`.
start_datestringAlias of `date_after`.
end_datestringAlias of `date_before`.

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

NameTypeDescription
zip_codestring5-digit ZIP code
citystringCity name
statestring2-letter state code
jurisdictionstringJurisdiction name (e.g. 'Wake County', 'Tacoma') or partial match
addressstringStreet address, partial + case-insensitive (e.g. '1600 Pennsylvania') — indexed, fast
latnumberLatitude for radius search
lngnumberLongitude for radius search
radius_milesnumberRadius in miles (used with lat/lng)
default: 5.0
fieldsstring'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"
bboxstringMap-viewport bounding box 'minLng,minLat,maxLng,maxLat'. Returns permits whose location falls inside the box (geocoded permits only).
polygonstringA drawn area as a GeoJSON Polygon geometry (URL-encoded), e.g. {"type":"Polygon","coordinates":[[[lng,lat],...]]}. Returns permits inside the polygon (geocoded permits only).
categorystringPermit category (e.g. solar, SOLAR, roofing, hvac — case insensitive)
statusPermitStatusPermit status (e.g. issued, filed, final)
property_typePropertyTypeProperty type (e.g. residential, commercial)
tagstringFilter by tag
record_kindstringRecord 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_afterstringFiled on or after this date
filed_beforestringFiled on or before this date
issued_afterstringIssued on or after this date
issued_beforestringIssued on or before this date
date_afterstringOn 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_beforestringOn or before this date, matched against whichever date a record has (issued, else filed).
parcelstringParcel number / APN / folio (formatting ignored). Returns permits on that parcel where the source publishes one.
min_valuenumberMinimum estimated value
max_valuenumberMaximum estimated value
min_solar_kwnumberMinimum extracted solar system size (kW DC)
max_solar_kwnumberMaximum extracted solar system size (kW DC)
min_sqftnumberMinimum square footage mentioned (from enrichment)
has_enrichmentbooleanOnly permits that have (true) or lack (false) LLM enrichment
scopestringSubstring match on the enriched work scope
qstringCase-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_namestringContractor name (partial match)
owner_filedbooleantrue = 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_addressbooleantrue = 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.
pageinteger
default: 1
per_pageintegerResults per page. Above your plan's maximum this is clamped, not rejected; the response echoes the per_page actually applied.
default: 25
keywordstringAlias of `q`.
date_fromstringAlias of `date_after`.
date_tostringAlias of `date_before`.
start_datestringAlias of `date_after`.
end_datestringAlias of `date_before`.
limitintegerAlias of `per_page`. Clamped to your plan's maximum, not rejected.
count_onlybooleanReturn 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

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitSummary[]
hintsHint[]
coverage_confidenceCoverageConfidence
tier_window_daysinteger
tier_window_fromstring
tier_window_clampedboolean
tier_window_upgrade_urlstring
locked_fieldsstring[]
locked_upgrade_urlstring
coverageJurisdictionCoverage[]
total_cappedboolean
total_unknownboolean

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
429Rate 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

NameTypeDescription
cursorstringOpaque cursor from the previous response's `next_cursor`. Omit to start the initial full sync from the beginning.
sincestringAlternative start point: an ISO-8601 UTC timestamp; returns permits with updated_at >= since. Ignored when `cursor` is supplied.
limitintegerMax permits per page (cursor page size, up to 50,000).
default: 5000
record_kindstring'permit' (default), a specific record_kind, or 'all'.
default: "permit"
statestringOptional 2-letter state filter to scope the feed.
categorystringOptional category filter (e.g. solar, roofing).
fieldsstring'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

CodeMeaning
200A 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.
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

NameTypeDescription
permit_idrequiredstring

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
idrequiredstringStable PermitStack id. Pass to GET /v1/permits/{id}."f4be62ed-b522-42a3-907b-b4ab4ec1a102"
permit_numberrequiredstringThe permit number as the issuing jurisdiction publishes it. Not unique across jurisdictions, and not always present."R2608-181"
statusrequiredenumOne 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"
categoryrequiredenumTrade/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"
tagsrequiredstring[]Free-form keywords extracted from the description (e.g. 'pool', 'reroof'). Additive; do not rely on a fixed vocabulary.["pool"]
property_typerequiredstringRESIDENTIAL, COMMERCIAL, INDUSTRIAL, MIXED_USE or UNKNOWN. UNKNOWN is common -- many feeds publish nothing that implies a property type."RESIDENTIAL"
address_streetrequiredstringStreet address as published. Stored verbatim, so spacing can be irregular; our address matching normalises whitespace for you."288 Mosaic Blvd"
address_cityrequiredstringCity. Null where the feed publishes none -- notably county-wide feeds. Read null as unknown, never as 'not in a city'."Daytona Beach"
address_staterequiredstring2-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_ziprequiredstring5-digit ZIP where published."32124"
parcel_idstringAssessor 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_rawrequiredstringThe 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_valuerequirednumberDeclared job value in USD, as published. Frequently null and occasionally nominal -- treat 0 and 1 as 'not stated'.24500.0
date_filedrequiredstringApplication/filing date. Null where the source publishes only an issue date."2026-08-14"
date_issuedrequiredstringIssue 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_completedrequiredstringCompletion/final date where the source publishes one. Usually null.
approval_daysintegerCalendar days from date_filed to date_issued (null unless both dates present)
construction_daysintegerCalendar days from date_issued to date_completed (null unless both dates present)
contractor_namestringContractor 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_idstringOpaque 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_namestringOwner 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_addressstringProperty-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_namestringThe PermitStack data source this permit came from -- a city, county or statewide feed, which is not always the permit's own city."Daytona Beach"
latitudenumberWGS84 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
longitudenumberWGS84 longitude.-81.0559
location_sourcestringWhere 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"
enrichmentPermitEnrichmentStructured detail parsed from the description (e.g. solar kW) where we could extract it. Null for most permits.
record_kindstringpermit | 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_expiredrequiredstringExpiry 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_amountnumberPermit/inspection fee. Frequently null — most open-data feeds do not publish fee data.
storiesrequiredintegerBuilding stories, where published.1
unitsrequiredintegerDwelling/tenant units, where published.1
square_footagerequirednumberProject square footage, where published. Usually null.2100.0
applicant_namerequiredstringWhoever 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_licensestringContractor'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_atstringWhen 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

FieldTypeDescription
scopestring
work_summarystring
solar_kwnumber
sqftnumber
unitsinteger
is_residentialboolean
is_commercialboolean
materialsstring[]

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

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
429Rate 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

NameTypeDescription
statestring2-letter state code
citystringCity name
zip_codestring5-digit ZIP
jurisdictionstringJurisdiction name (partial)
min_age_yearsnumberMinimum age of the PV permit, in years
default: 2
max_age_yearsnumberMaximum age of the PV permit, in years
default: 7
pageinteger
default: 1
per_pageinteger
default: 25

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitSummary[]
hintsHint[]
coverage_confidenceCoverageConfidence
tier_window_daysinteger
tier_window_fromstring
tier_window_clampedboolean
tier_window_upgrade_urlstring
locked_fieldsstring[]
locked_upgrade_urlstring
coverageJurisdictionCoverage[]
total_cappedboolean
total_unknownboolean

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
installerrequiredstringInstaller name to match (contractor/applicant/owner)
statestring2-letter state code
citystringCity name
zip_codestring5-digit ZIP
jurisdictionstringJurisdiction name (partial)
pageinteger
default: 1
per_pageinteger
default: 25

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitSummary[]
hintsHint[]
coverage_confidenceCoverageConfidence
tier_window_daysinteger
tier_window_fromstring
tier_window_clampedboolean
tier_window_upgrade_urlstring
locked_fieldsstring[]
locked_upgrade_urlstring
coverageJurisdictionCoverage[]
total_cappedboolean
total_unknownboolean

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
statestring2-letter state code
citystringCity name
zip_codestring5-digit ZIP
jurisdictionstringJurisdiction name (partial)
min_age_yearsnumberMinimum age of the roofing permit, in years
default: 12
max_age_yearsnumberMaximum age of the roofing permit, in years
default: 25
pageinteger
default: 1
per_pageinteger
default: 25

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitSummary[]
hintsHint[]
coverage_confidenceCoverageConfidence
tier_window_daysinteger
tier_window_fromstring
tier_window_clampedboolean
tier_window_upgrade_urlstring
locked_fieldsstring[]
locked_upgrade_urlstring
coverageJurisdictionCoverage[]
total_cappedboolean
total_unknownboolean

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
traderequiredstringroofing | hvac | mechanical | solar | pool
statestring2-letter state code
citystringCity name
zip_codestring5-digit ZIP
jurisdictionstringJurisdiction name (partial)
min_age_yearsnumberOverride the trade's default minimum age
max_age_yearsnumberOverride the trade's default maximum age
pageinteger
default: 1
per_pageinteger
default: 25

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredPermitSummary[]
hintsHint[]
coverage_confidenceCoverageConfidence
tier_window_daysinteger
tier_window_fromstring
tier_window_clampedboolean
tier_window_upgrade_urlstring
locked_fieldsstring[]
locked_upgrade_urlstring
coverageJurisdictionCoverage[]
total_cappedboolean
total_unknownboolean

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
parcelrequiredstringParcel number / APN / folio. Formatting is ignored — dashes, dots and spaces are stripped before matching.
statestringOptional 2-letter state to disambiguate the same parcel number across counties
pageinteger
default: 1
per_pageinteger
default: 50
limitintegerAlias of per_page.

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
queryrequiredPropertyQuery
foundrequiredboolean
total_matchesrequiredinteger
truncatedrequiredboolean
distinct_addressesinteger
distinct_jurisdictionsinteger
fan_out_warningstring
summaryrequiredPropertySummary
coverageCoverageConfidence
data_currencyJurisdictionCoverage[]
pagerequiredinteger
per_pagerequiredinteger
has_morerequiredboolean
permitsrequiredPermitSummary[]

PropertyQuery

FieldTypeDescription
addressstring
parcelstring
citystring
statestring
zipstring

PropertySummary

FieldTypeDescription
total_permitsrequiredinteger
first_permit_datestring
last_permit_datestring
years_of_historyinteger
total_estimated_valuenumber
categoriesrequiredobject
jurisdictionsrequiredstring[]
contractorsrequiredstring[]
signalsrequiredPropertySignals

PropertySignals

FieldTypeDescription
has_solarboolean
solar_kwnumber
last_solar_datestring
has_batteryboolean
last_battery_datestring
has_roofingboolean
last_roofing_datestring
has_poolboolean
last_pool_datestring
has_additionboolean
has_new_constructionboolean
has_electricalboolean
has_hvacboolean
last_hvac_datestring
has_plumbingboolean
has_demolitionboolean
last_activity_datestring

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
addressrequiredstringStreet address to look up (e.g. '123 Main St')
citystringOptional city to disambiguate identical street addresses
statestringOptional 2-letter state code
zipstringOptional ZIP (prefix-matched)
pageinteger
default: 1
per_pageinteger
default: 50
limitintegerAlias of per_page.

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
queryrequiredPropertyQuery
foundrequiredboolean
total_matchesrequiredinteger
truncatedrequiredboolean
distinct_addressesinteger
distinct_jurisdictionsinteger
fan_out_warningstring
summaryrequiredPropertySummary
coverageCoverageConfidence
data_currencyJurisdictionCoverage[]
pagerequiredinteger
per_pagerequiredinteger
has_morerequiredboolean
permitsrequiredPermitSummary[]

PropertyQuery

FieldTypeDescription
addressstring
parcelstring
citystring
statestring
zipstring

PropertySummary

FieldTypeDescription
total_permitsrequiredinteger
first_permit_datestring
last_permit_datestring
years_of_historyinteger
total_estimated_valuenumber
categoriesrequiredobject
jurisdictionsrequiredstring[]
contractorsrequiredstring[]
signalsrequiredPropertySignals

PropertySignals

FieldTypeDescription
has_solarboolean
solar_kwnumber
last_solar_datestring
has_batteryboolean
last_battery_datestring
has_roofingboolean
last_roofing_datestring
has_poolboolean
last_pool_datestring
has_additionboolean
has_new_constructionboolean
has_electricalboolean
has_hvacboolean
last_hvac_datestring
has_plumbingboolean
has_demolitionboolean
last_activity_datestring

CoverageConfidence

FieldTypeDescription
confidencerequiredstring
coveredrequiredboolean
jurisdictionstring
data_statusstring
data_throughstring
freshnessstring
jurisdiction_permit_countinteger
noterequiredstring
tier_window_daysinteger
tier_window_fromstring

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

NameTypeDescription
namestringContractor name (partial match)
statestring2-letter state code
citystringCity name
specialtystringSpecialty tag (e.g. solar, roofing, hvac)
license_numberstringExact 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_statestring2-letter state that ISSUED the licence. Not the same as `state`, which is where the contractor pulls permits.
min_permitsintegerMinimum total permits
min_scoreintegerMinimum contractor activity score (0-100)
sortstringSort order: 'score' (default), 'permits', or 'recent'
default: "score"
pageinteger
default: 1
per_pageinteger
default: 25

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
totalrequiredinteger
pagerequiredinteger
per_pagerequiredinteger
resultsrequiredContractorSummary[]
specialties_matchedstring[]

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

NameTypeDescription
contractor_idrequiredstring

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

FieldTypeDescription
idrequiredstring
namerequiredstring
license_numberrequiredstring
license_staterequiredstring
cityrequiredstring
staterequiredstring
total_permitsrequiredinteger
first_permit_daterequiredstring
last_permit_daterequiredstring
specialtiesrequiredstring[]
scoreinteger
is_businessboolean
phonerequiredstring
emailrequiredstring
contact_lockedboolean
locked_fieldsstring[]
upgrade_urlstring
addressrequiredstring
zip_coderequiredstring
recent_categoriesstring[]
avg_project_valuenumber

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

NameTypeDescription
contractor_idrequiredstring
pageinteger
default: 1
per_pageinteger
default: 25

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

NameTypeDescription
staterequiredstring
categorystring
monthsinteger
default: 12
limitinteger
default: 50

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

NameTypeDescription
statestring
citystring
daysintegerTrailing window. Rounded to whole months by the rollup.
default: 90

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

NameTypeDescription
statestringTwo-letter state code, e.g. FL
citystringCity name; case-insensitive
categorystringPermit category, e.g. ROOFING
monthsintegerHow many trailing months to return
default: 24

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
429Rate 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

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
403Your 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.
422Validation Error
429Rate 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

NameTypeDescription
webhook_idrequiredstring

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

NameTypeDescription
webhook_idrequiredstring
is_activerequiredbooleantrue re-activates a webhook the failure breaker switched off.

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

NameTypeDescription
webhook_idrequiredstring
limitintegerHow many recent failed attempts to return.
default: 20

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

NameTypeDescription
webhook_idrequiredstring

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

NameTypeDescription
webhook_idrequiredstring

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

NameTypeDescription
webhook_idrequiredstring

Responses

CodeMeaning
200Successful Response
401Missing or invalid API key. Pass a key as the `X-API-Key` header.
422Validation Error
429Rate 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

CodeMeaning
200Successful 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

CodeMeaning
200Successful Response

Example

curl -H "X-API-Key: pk_your_key_here" \
  "https://api.permit-stack.com/stats"