Thousands of permit portals. One schema. One API key.
Every US jurisdiction publishes building permits differently — different columns, different date formats, different names for the same party. We normalize them into one stable schema and serve it as a filterable REST API and a semantic search endpoint your agents can query directly.
Metered per 1,000 requests · No minimum · OpenAPI schema included
curl -H "X-API-Key: $EXHAUST_API_KEY" \
"https://permitsapi.com/api/v1/records\
?state=WA&work_class=solar&min_valuation=25000"
{
"data": [
{
"permit_number": "6912345-CN",
"jurisdiction": "City of Seattle",
"state": "WA",
"address_line": "1420 NW 85th St",
"postal_code": "98117",
"work_class": "solar",
"work_description": "Install 8.2kW rooftop PV",
"contractor_name": "Blue Ridge Construction",
"owner_name": "M. Ellison",
"valuation_usd": "31500.00",
"status_text": "Permit Issued",
"issued_date": "2025-09-02",
"source_url": "https://cos-data.seattle…",
"scraped_at": "2025-09-03T04:12:07Z"
}
],
"meta": { "returned": 50, "has_more": true }
}
Public data is not usable data.
Permit records are public almost everywhere. That is exactly why nobody has them in a form you can query. The work is not access — it is the thousand small incompatibilities between one jurisdiction and the next.
Every column is named differently
One city's issued_date is another's DateIssued is another's
permit_issue_dt. The same field means different things depending on who published it.
Dates lie about their format
03/11/2024 is March 11th in most of the country and November 3rd in
others. Guessing wrong silently corrupts every time-series you build on it.
Parties get mixed up
The "permittee" is the contractor in one dataset and the property owner in the next. Mapping them onto the wrong field misattributes work to the wrong company.
A schema you can build on.
One normalized record type
Addresses parsed into components, dates resolved against each jurisdiction's own convention, valuations as decimals, parties assigned to the role they actually held. Field names are stable API surface — we version the schema rather than renaming columns under you.
Semantic search, not just filters
Ask for "rooftop solar installs over $25k" and get ranked results with cosine similarity — no embedding cost on your side. Filters are applied inside the vector query, so a narrow filter still returns a full page of hits.
Provenance on every row
Each record carries the source_url it came from, the
source_name of the publisher and the scraped_at timestamp. When
a customer or a regulator asks where a number came from, you can answer with a link.
Incremental sync built in
Pass updated_since and pull only what changed. Records are keyed by a
content hash, so a republished page updates in place instead of duplicating — your
mirror stays consistent without a reconciliation job.
Two endpoints. Both boring, on purpose.
Authenticate with an X-API-Key header. Every response is JSON, every error
carries a request_id, and the full OpenAPI schema is published at
/docs.
# Filter structured records. Every parameter is optional.
curl -H "X-API-Key: $EXHAUST_API_KEY" \
"https://permitsapi.com/api/v1/records?\
jurisdiction=City+of+Seattle&\
work_class=roofing&\
issued_after=2025-01-01&\
min_valuation=10000&\
order_by=issued_date&\
limit=50"
# Same query, same filters, as a spreadsheet.
curl -H "X-API-Key: $EXHAUST_API_KEY" -H "Accept: text/csv" \
"https://permitsapi.com/api/v1/records?state=WA&work_class=roofing" \
-o wa-roofing.csv
# Pull only what changed since your last sync.
curl -H "X-API-Key: $EXHAUST_API_KEY" \
"https://permitsapi.com/api/v1/records?\
updated_since=2025-09-01T00:00:00Z&limit=200"
# Natural language in, ranked records out. No embedding cost on your side.
curl -X POST "https://permitsapi.com/api/v1/records/search" \
-H "X-API-Key: $EXHAUST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "rooftop solar installs over $25k",
"state": "WA",
"issued_after": "2025-01-01",
"limit": 10
}'
{
"data": [
{
"permit_number": "6912345-CN",
"work_description": "Install 8.2kW rooftop PV",
"similarity": 0.891,
"chunk_text": "Building Permit in City of Seattle…"
}
],
"returned": 10,
"took_ms": 38
}
import os, httpx
client = httpx.Client(
base_url="https://permitsapi.com/api/v1",
headers={"X-API-Key": os.environ["EXHAUST_API_KEY"]},
timeout=30,
)
# Page through a jurisdiction with a stable offset walk.
offset = 0
while True:
page = client.get("/records", params={
"state": "WA",
"work_class": "solar",
"limit": 200,
"offset": offset,
}).raise_for_status().json()
for record in page["data"]:
handle(record)
if not page["meta"]["has_more"]:
break
offset += 200
# Each hit ships the exact text we embedded, so your RAG store
# can use it verbatim — you never pay to embed our corpus.
from langchain_core.documents import Document
hits = client.post("/records/search", json={
"query": "commercial reroofing over $100k",
"state": "TX",
"include_chunk_text": True,
"limit": 50,
}).json()["data"]
docs = [
Document(
page_content=hit["chunk_text"],
metadata={
"permit_number": hit["permit_number"],
"jurisdiction": hit["jurisdiction"],
"issued_date": hit["issued_date"],
"source_url": hit["source_url"],
},
)
for hit in hits
]
A permit is a signal that someone is about to spend money.
Contractor lead gen
A pulled roofing permit means a homeowner is mid-project. Filter by trade, ZIP and valuation, and route the lead the same day it is published.
Property intelligence
Renovation history is the missing column in most property datasets. Join on parcel id or address and know what was actually done to a building.
Insurance & risk
Unpermitted work, roof age, electrical upgrades. Underwriting signals that show up in permits years before they show up in a claim.
AI agents & RAG
The search endpoint returns ranked chunks with metadata, ready to hand to a retriever. Your agent asks a question in English and gets sourced records back.
Growing jurisdiction by jurisdiction.
We add a jurisdiction only once every essential field maps cleanly. A dataset that is missing issue dates or mislabels the contractor is worse than no dataset at all, so it does not ship until it is right.
| Jurisdiction | State | Earliest record | Status |
|---|---|---|---|
| City of Seattle | WA | — | Live |
| More jurisdictions in onboarding — ask us which ones you need. | |||
Need a specific city or county? Tell us. Adding a jurisdiction that already publishes an open dataset is usually days, not months.
Metered. No seats, no annual lock-in.
You are billed for what you pull, in blocks of 1,000. Semantic queries are metered separately from record reads because they cost us more to serve.
Prove it fits before you pay.
10,000 records, one-time
- Full schema, no sampling
- Both endpoints enabled
- 60 requests / minute
- Email support
Production workloads.
$2.00 per 1k semantic queries
- Unlimited jurisdictions
- Incremental sync via
updated_since - 600 requests / minute
- 99.5% uptime target
Whole-corpus mirrors.
Volume rates and flat-fee options
- Full historical backfill
- Jurisdiction requests prioritized
- Custom rate limits
- Redistribution terms available
Before you ask.
Where does the data come from?
Public records published by the jurisdictions themselves — open data portals and
municipal permit systems. Every record links back to the exact source page it came from
via source_url, so nothing we serve is unverifiable.
How fresh is it?
Sources are re-pulled on a daily schedule. Freshness is bounded by the publisher: a
city that updates its dataset weekly cannot be fresher than weekly no matter how often we
poll it. Each record's scraped_at tells you exactly when we last saw it.
Can I redistribute the data?
Standard plans license the API for use inside your product. Redistributing the raw corpus — reselling it, or publishing it as a dataset of its own — needs different terms. Ask and we will sort it out; it is not a hard no.
What happens when I hit the rate limit?
You get a 429 with a Retry-After header, and every response
carries X-RateLimit-Limit and X-RateLimit-Remaining so you can
back off before you get there. Rate limits are per key and
adjustable on request.
Do you have the jurisdiction I need?
Check the coverage table, and if it is not there, ask. If the jurisdiction publishes an open dataset we can usually onboard it in days. If it only has a search portal with no export, it takes longer and we will tell you honestly which one it is.
Why not just scrape it myself?
You can — it is public data. The cost is not access, it is the long tail: date formats that differ by county, party roles that swap meaning between datasets, portals that change their HTML without warning, and the ongoing job of noticing when one breaks. That maintenance is the product.
Start with a trial key.
Tell us what you are building and which jurisdictions you need. We will send a key and enough records to prove the schema fits before you spend anything.