API reference
OpenPermits API
Paths and parameters follow the Shovels v2 API, so existing clients can switch by changing the base URL and key. Create a key under API keys and send it in the X-API-Key header from your servers. Money is in cents; dates are YYYY-MM-DD.
curl https://api.openpermits.ai/v2/permits/search \ -H "X-API-Key: $OPENPERMITS_KEY" \ -G -d geo_id=ga/atlanta -d permit_tags=SOLAR,-POOL_AND_HOT_TUB \ -d permit_from=2026-01-01 -d permit_to=2026-12-31 -d size=100
Pages return next_cursor; pass it back as cursor for the next page. To keep a copy in sync, page through once, then ask only for what changed with updated_since (or first_seen_since for new permits only). For new permits without gaps, stop each poll at settled_through from /v2/meta/release (pass it as first_seen_before) and start the next one there: permits before it are fully geocoded and linked. The full machine-readable spec is at /v2/openapi.json.
Credits and limits
Data endpoints cost one credit per record returned, and one per single-record lookup. Empty results, include_count and the reference endpoints are free. Every charged response reports your allowance:
X-Credits-Charged: 100 X-Credits-Used: 12400 X-Credits-Limit: 50000 X-Credits-Remaining: 37600 X-Credits-Reset: 2026-10-25
Errors
Errors are JSON with a detail, a stable code where you might branch on it, and a request_id to quote to support.
| 401 | Missing, invalid or revoked key (code key_revoked) |
| 402 | Monthly credits used up (code credits_exhausted); resets on the date in X-Credits-Reset |
| 403 | No active subscription (code subscription_inactive) |
| 404 | No such record |
| 422 | A parameter is missing or invalid; detail says which |
| 429 | Over your rate limit (code rate_limited); wait Retry-After seconds |
Webhooks
Turn on webhooks for a saved search and add an endpoint under Settings → Webhooks. After each data refresh we POSTthe search's new permits to every endpoint that's on, up to 100 per event, in the same shape as /v2/permits/search. Each permit costs one credit. Reply with any 2xx within 10 seconds; anything else is retried with backoff for about a day, and redirects aren't followed. The same event can arrive twice, so use id to ignore repeats.
POST /your/endpoint
OP-Event-Id: evt_…
OP-Event-Type: permits.new
OP-Signature: t=1790000000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{
"id": "evt_…",
"type": "permits.new",
"created": "2026-09-25T13:15:04.120Z",
"saved_search": { "id": "…", "name": "Roofing in 30306", "url": "https://openpermits.ai/search?…" },
"window": { "first_seen_since": "2026-09-25T06:02:11.000Z", "first_seen_before": "2026-09-25T12:03:40.000Z" },
"page": 0,
"permits": [ { "id": "…", "number": "BLD2026-01234", "tags": ["ROOFING"], … } ]
}Check the signature before trusting a request: HMAC-SHA256 of `${t}.${raw body}`with your endpoint's signing secret must equal v1, and t should be within five minutes of now.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(header, rawBody, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}Permits
/v2/permits/export.csvExport permits as CSV
The same filters as `/v2/permits/search`, streamed as one CSV file, newest first. Costs one credit per row. The export stops at your remaining allowance or `limit`, whichever is smaller; `X-Export-Row-Limit` says which. Text cells that a spreadsheet would run as a formula are prefixed with an apostrophe.
| Parameter | Type | Description |
|---|---|---|
permit_fromrequired | string | Start of the permit-date range (issue date, else file date). |
permit_torequired | string | |
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
permit_tags | string | |
permit_status | string | |
permit_q | string | |
permit_min_job_value | integer | |
property_type | string | |
contractor_id | string | |
address_id | string | |
updated_since | string | |
first_seen_since | string | |
first_seen_before | string | |
bbox | string | Only permits inside a map box: `minLon,minLat,maxLon,maxLat`, at most 2 degrees each way. |
lat | number | With `lon` and `radius_m`: only permits within that distance of a point. |
lon | number | |
radius_m | number | Metres, up to 50,000. |
limit | integer | At most this many rows. Default 50000. |
/v2/permits/searchSearch permits
Newest first by permit date (issue date, else file date).
| Parameter | Type | Description |
|---|---|---|
permit_fromrequired | string | Start of the permit-date range (issue date, else file date). |
permit_torequired | string | |
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
permit_tags | string | Comma-separated tags (`/v2/list/tags`). Prefix a tag with `-` to exclude it. |
permit_status | string | Comma-separated statuses. |
permit_q | string | Text in the description or type, 3 to 50 characters. |
permit_min_job_value | integer | Minimum declared job value, in cents. |
permit_number | string | Exact permit number, case-insensitive. `%` and `_` act as wildcards. |
property_type | string | Comma-separated property types (`/v2/list/property_types`). |
contractor_id | string | |
address_id | string | Every permit at one address (`geo_ids.address_id` on a permit, or `id` from `/v2/addresses/search`). |
updated_since | string | Only permits created or changed since this time (RFC 3339 or a date). For incremental syncs. |
first_seen_since | string | Only permits we first saw since this time (RFC 3339 or a date). Unlike `updated_since`, status changes don't count. |
first_seen_before | string | Only permits we first saw before this time (exclusive). For gap-free polling, request `first_seen_since` = your last cutoff and `first_seen_before` = `settled_through` from `/v2/meta/release`, then keep that value as your next cutoff. |
bbox | string | Only permits inside a map box: `minLon,minLat,maxLon,maxLat`, at most 2 degrees each way. |
lat | number | With `lon` and `radius_m`: only permits within that distance of a point. |
lon | number | |
radius_m | number | Metres, up to 50,000. |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
include_count | boolean | Add `total_count` to the first page (exact up to 10,000). Free. Default false. |
/v2/permits/{permit_id}Get a permit, with its status history
| Parameter | Type | Description |
|---|---|---|
permit_idrequiredpath | string |
Contractors
/v2/contractors/searchSearch contractors
Sorted by permit count, highest first. Returns one representative per dedup group unless `include_members`.
| Parameter | Type | Description |
|---|---|---|
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
contractor_q | string | Text in the name or DBA. |
contractor_classification | string | Comma-separated classes (`/v2/list/contractor_classifications`). |
contractor_license | string | License number, punctuation-insensitive. |
contractor_min_permit_count | integer | |
include_members | boolean | Default false. |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
include_count | boolean | Add `total_count` to the first page (exact up to 10,000). Free. Default false. |
/v2/contractors/{contractor_id}Get a contractor, with its group and jurisdictions
| Parameter | Type | Description |
|---|---|---|
contractor_idrequiredpath | string |
/v2/contractors/{contractor_id}/permitsList a contractor's permits
| Parameter | Type | Description |
|---|---|---|
contractor_idrequiredpath | string | |
include_group | boolean | Include every branch in the contractor's group. Default false. |
permit_from | string | |
permit_to | string | |
permit_tags | string | |
permit_status | string | |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
include_count | boolean | Add `total_count` to the first page (exact up to 10,000). Free. Default false. |
Properties
/v2/addresses/{address_id}/systemsHow old an address's roof, HVAC and other systems are
For each building system, the last permitted work on it and how far through its typical lifespan it is. Where no permit is on record, the building's year built (from its assessor parcel) stands in, and the basis says so. Lifespans: roof 20 years, HVAC 15, water heater 10, plumbing 40, electrical 30, windows and doors 25. One credit.
| Parameter | Type | Description |
|---|---|---|
address_idrequiredpath | string |
/v2/properties/searchSearch properties (assessor parcels)
| Parameter | Type | Description |
|---|---|---|
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
property_type | string | |
owner_type | string | `individual`, `business`, `government`, ... |
apn | string | |
min_year_built | integer | |
max_year_built | integer | |
with_permits | boolean | Default false. |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
/v2/properties/{parcel_id}Get a property, with up to 50 of its permits and its system ages
| Parameter | Type | Description |
|---|---|---|
parcel_idrequiredpath | string |
Addresses
/v2/addresses/searchSearch addresses
| Parameter | Type | Description |
|---|---|---|
address_q | string | Free-text address; fuzzy-matched. |
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
with_permits | boolean | Only addresses with at least one permit. Default true. |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
Decisions
Zoning and land-use agenda items from city and county bodies.
/v2/decisions/searchSearch zoning and land-use decisions
| Parameter | Type | Description |
|---|---|---|
meeting_fromrequired | string | |
meeting_torequired | string | |
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
category | string | Comma-separated (`/v2/list/decision_categories`). |
status | string | |
zoning_from | string | |
zoning_to | string | |
q | string | Text in the title or description. |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
include_count | boolean | Add `total_count` to the first page (exact up to 10,000). Free. Default false. |
/v2/decisions/{decision_id}Get a decision
| Parameter | Type | Description |
|---|---|---|
decision_idrequiredpath | string |
Markets
Monthly permit activity and contractor market share for a geography, recomputed after every data refresh.
/v2/contractors/rankingsContractors by market share
The contractors pulling the most permits in a geography and period, with their share of all permits there, including permits that name no contractor. One credit per contractor returned.
| Parameter | Type | Description |
|---|---|---|
geo_idrequired | string | A state (`GA`), county FIPS (`13121`), zip, jurisdiction slug (`ga/atlanta`), or a city `geo_id` from `/v2/geography/search`. |
tag | string | One work-type tag, or `*` (the default) for all permits. Default *. |
from | string | First month, `YYYY-MM`. Defaults to 24 months before `to` (12 for rankings). |
to | string | Last month, `YYYY-MM`. Defaults to this month. |
size | integer | Default 25. |
/v2/markets/metricsMonthly permit activity for a place
Permit counts, job value (total and median), average approval time and active contractors per month, overall or for up to six work-type tags. Months with no permits are omitted. One credit per row.
| Parameter | Type | Description |
|---|---|---|
geo_idrequired | string | A state (`GA`), county FIPS (`13121`), zip, jurisdiction slug (`ga/atlanta`), or a city `geo_id` from `/v2/geography/search`. |
tag | string | Comma-separated work-type tags, or `*` (the default) for all permits. Default *. |
from | string | First month, `YYYY-MM`. Defaults to 24 months before `to` (12 for rankings). |
to | string | Last month, `YYYY-MM`. Defaults to this month. |
Signals
Ready-made lead and risk lists built from permit history.
/v2/signals/aging-systemsAddresses whose roof, HVAC or other system is getting old
Addresses in a place where a building system is older than `min_age_years` (by default its typical lifespan). The evidence is either the last permit for that system (`basis: permit`) or, where there is none, the building's year built (`basis: year_built`, wherever assessor parcels are loaded). One credit per address.
| Parameter | Type | Description |
|---|---|---|
geo_idrequired | string | A state (`GA`), county FIPS (`13121`), zip, jurisdiction slug (`ga/atlanta`), or a city `geo_id` from `/v2/geography/search`. |
systemrequired | string | |
min_age_years | integer | |
basis | string | Default all. |
property_type | string | Comma-separated (`/v2/list/property_types`). |
size | integer | Default 10. |
cursor | string | `next_cursor` from the previous page. |
Reference
/v2/geography/searchFind a geo_id for a state, county, city, zip or jurisdiction
| Parameter | Type | Description |
|---|---|---|
qrequired | string | |
kind | string | |
size | integer | Default 20. |
/v2/jurisdictions/searchList covered jurisdictions, with permit counts and date ranges
| Parameter | Type | Description |
|---|---|---|
q | string | |
size | integer | Default 50. |
/v2/list/contractor_classificationsContractor classes
/v2/list/decision_categoriesDecision categories
/v2/list/property_typesProperty types
/v2/list/tagsWork-type tags
/v2/meta/coverageHow often each permit field is filled, for a place and period
| Parameter | Type | Description |
|---|---|---|
permit_fromrequired | string | Start of the permit-date range (issue date, else file date). |
permit_torequired | string | |
geo_id | string | A state (`GA`), 5-digit zip or county FIPS (`30319`, `13121`), jurisdiction slug (`ga/atlanta`), or any `geo_id` from `/v2/geography/search`. |
/v2/meta/releaseWhen each part of the data was last refreshed
Account
/v2/usageYour allowances in the current monthly window