Documentation
PermitFlow API
A REST API for US construction permits and contractor licenses. JSON in, JSON out. Versioned. Cursor-paginated. Webhook-friendly. Every response carries an X-Request-Id so you can grep both sides of a failure.
Why this API exists
The 2023–2024 permit API graveyard
After Tyler Technologies acquired Socrata in 2023, dozens of US cities quietly migrated off open data portals onto closed permitting systems. The endpoints your last vendor built on either disappeared, fell behind by weeks, or got rate-limited into uselessness.
We kept the survivors alive (Socrata, ArcGIS, CKAN, CARTO) and negotiated, scraped, or parsed direct access to the rest. The result is one REST endpoint that hides every upstream quirk behind a single schema — and a coverage map your competitors can't rebuild without a year of plumbing work.
See live status at /coverage — public, no API key, updated every ingestion run.
Coverage & expansion
The US has ~3,000+ permit-issuing jurisdictions. Most aren't worth integrating — they don't exist online, or only behind a vendor paywall that killed their predecessors. We tier the country by what's actually buildable and useful:
| Tier | Cities | % of US permit volume | Source | Status |
|---|---|---|---|---|
| 1 | ~12 | ~40% | Free APIs (Socrata, ArcGIS, CKAN, CARTO) | Live |
| 2 | ~50–100 | +20% | HTML portal scraping (public records) | Customer-pulled |
| 3 | ~200–500 | +10% | PDF/CSV parsing + OCR (weekly publications) | Onboarding fee |
| 4 | ~2,400 | ~30% | Closed Accela/Tyler/OpenGov tenants | Won't build |
Realistic ceiling: ~150 cities ≈ 70% of US permit volume over 18–24 months, expanded as paying customers pull us into specific jurisdictions. We don't speculatively scrape — every tier 2/3 city is underwritten by a contract. The remaining 30% lives behind vendor paywalls; if a city is only available through Tyler/OpenGov enterprise tenants, we tell you up front and you skip it.
Need a city we don't cover? Email hello@permitflow.dev with the jurisdiction and your monthly volume. We'll quote tier 2 (subscription premium) or tier 3 (one-time onboarding) within 48h.
Authentication
All requests are authenticated with a bearer token. Get yours from the pricing page. Free tier includes 100 calls/mo, no credit card.
Authorization: Bearer pf_live_a1b2c3d4e5f6g7h8Optional: pass X-Request-Id on any request to set your own correlation ID. Otherwise we generate one and return it in the response header — include it when contacting support.
List permits
Returns a paginated list of permits, ordered by issued_date DESC with permit_number DESC as a stable tiebreak.
| Param | Type | Description |
|---|---|---|
| city | string | Case-insensitive city name, e.g. Phoenix |
| state | string | Two-letter state code, e.g. AZ |
| type | string | Substring match: solar, hvac, roofing, pool, addition, electrical, commercial |
| status | string | Permit status, e.g. Issued |
| issued_since | date | ISO date — only permits issued on/after |
| limit | int | 1–200 (default 50) |
| offset | int | Zero-based offset. Ignored when cursor is supplied. |
| cursor | string | Opaque cursor from a previous response's next_cursor. Recommended for large syncs — keyset-paginated and stable under inserts. |
curl https://permitflow.dev/api/public/v1/permits \
-H "Authorization: Bearer pf_live_..." \
-H "X-Request-Id: my-job-2026-04-28-001" \
-G \
-d city=Phoenix \
-d type=solar \
-d issued_since=2026-04-01 \
-d limit=25{
"data": [
{
"permit_number": "BLD2025-04123",
"city": "Phoenix",
"state": "AZ",
"zip": "85016",
"address": "2847 E Camelback Rd",
"type": "Solar",
"status": "Issued",
"valuation": 24500,
"description": "7.2kW rooftop solar PV system, 18 panels",
"contractor": {
"name": "SunPro Solar of Arizona",
"license": "ROC-289341"
},
"issued_date": "2025-04-22",
"applied_date": "2025-04-08",
"ingested_at": "2025-04-23T03:14:00Z"
}
],
"pagination": {
"total": 8421,
"limit": 25,
"offset": 0,
"has_more": true,
"next_cursor": "MjAyNS0wNC0yMnxCTEQyMDI1LTA0MTIz"
}
}Pagination tip
For backfills or nightly syncs, use cursor — it's keyset-based and won't skip rows when new permits land mid-scroll. Use offset only for ad-hoc browsing.
Get permit
Fetch a single permit by permit_number. Returns 404 permit_not_found if no match. URL-encode IDs that contain special characters.
curl https://permitflow.dev/api/public/v1/permits/BLD2025-04123 \
-H "Authorization: Bearer pf_live_..."{
"data": {
"permit_number": "BLD2025-04123",
"city": "Phoenix",
"state": "AZ",
"type": "Solar",
"status": "Issued",
"valuation": 24500,
"issued_date": "2025-04-22",
"ingested_at": "2025-04-23T03:14:00Z"
}
}Health check
Public, unauthenticated. Returns service status and database connectivity latency. Hit this from CI before integrating, or from your monitoring system to alert on outages.
{
"status": "ok",
"version": "v1",
"time": "2026-04-28T12:00:00Z",
"checks": {
"database": { "ok": true, "latency_ms": 12 }
}
}Contractor lookup
Verify a contractor license against the source state board. Returns license status, classifications, expiration, and the last 90 days of permit activity.
{
"license_number": "ROC-289341",
"name": "SunPro Solar of Arizona",
"state": "AZ",
"status": "Active",
"classifications": ["C-11 Solar"],
"expires": "2026-08-31",
"permits_last_90d": 142
}Webhooks
Subscribe to permit.issued and get a POST within minutes of new filings hitting source portals. Each delivery is signed with HMAC-SHA256 — verify the X-PermitFlow-Signature header against your subscription's signing_secret.
{
"type": "permit.created",
"created": 1714325400,
"data": { /* permit object */ }
}Errors
Standard HTTP codes. Every error returns the same JSON envelope — including a request_id you can quote when contacting support.
{
"error": {
"code": "permit_not_found",
"message": "No permit found with permit_number \"BLD2099-XXXX\".",
"request_id": "req_8a3c14f0e9b14c5b"
}
}| Param | Type | Description |
|---|---|---|
| 400 invalid_cursor | string | Cursor is malformed. Drop it and start fresh. |
| 400 invalid_permit_id | string | Permit ID is empty or > 128 chars. |
| 401 missing_api_key | string | No Authorization: Bearer pf_... header. |
| 401 invalid_api_key | string | Key not found or revoked. Rotate from /dashboard. |
| 404 permit_not_found | string | No permit matches that permit_number. |
| 429 quota_exceeded | string | Monthly call quota hit. Upgrade or wait for reset. |
| 500 query_error | string | Upstream issue — quote the request_id to support. |
Rate limits
Monthly call quota by plan, returned on every response in X-RateLimit-Limit and X-RateLimit-Remaining headers:
- Free — 100 calls/mo
- Starter — 10,000 calls/mo
- Growth — 100,000 calls/mo
- Scale — 1,000,000 calls/mo
Quota resets on the 1st of each month. Exceeding returns 429 quota_exceeded.