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:

TierCities% of US permit volumeSourceStatus
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 tenantsWon'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 header
Authorization: Bearer pf_live_a1b2c3d4e5f6g7h8

Optional: 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

GET/v1/permits

Returns a paginated list of permits, ordered by issued_date DESC with permit_number DESC as a stable tiebreak.

ParamTypeDescription
citystringCase-insensitive city name, e.g. Phoenix
statestringTwo-letter state code, e.g. AZ
typestringSubstring match: solar, hvac, roofing, pool, addition, electrical, commercial
statusstringPermit status, e.g. Issued
issued_sincedateISO date — only permits issued on/after
limitint1–200 (default 50)
offsetintZero-based offset. Ignored when cursor is supplied.
cursorstringOpaque cursor from a previous response's next_cursor. Recommended for large syncs — keyset-paginated and stable under inserts.
Request
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
Response
{
  "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

GET/v1/permits/{permit_number}

Fetch a single permit by permit_number. Returns 404 permit_not_found if no match. URL-encode IDs that contain special characters.

Request
curl https://permitflow.dev/api/public/v1/permits/BLD2025-04123 \
  -H "Authorization: Bearer pf_live_..."
Response
{
  "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

GET/v1/health

Public, unauthenticated. Returns service status and database connectivity latency. Hit this from CI before integrating, or from your monitoring system to alert on outages.

Response
{
  "status": "ok",
  "version": "v1",
  "time": "2026-04-28T12:00:00Z",
  "checks": {
    "database": { "ok": true, "latency_ms": 12 }
  }
}

Contractor lookup

GET/v1/contractors/{license_number}

Verify a contractor license against the source state board. Returns license status, classifications, expiration, and the last 90 days of permit activity.

Response
{
  "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.

POST your endpoint
{
  "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 response
{
  "error": {
    "code": "permit_not_found",
    "message": "No permit found with permit_number \"BLD2099-XXXX\".",
    "request_id": "req_8a3c14f0e9b14c5b"
  }
}
ParamTypeDescription
400 invalid_cursorstringCursor is malformed. Drop it and start fresh.
400 invalid_permit_idstringPermit ID is empty or > 128 chars.
401 missing_api_keystringNo Authorization: Bearer pf_... header.
401 invalid_api_keystringKey not found or revoked. Rotate from /dashboard.
404 permit_not_foundstringNo permit matches that permit_number.
429 quota_exceededstringMonthly call quota hit. Upgrade or wait for reset.
500 query_errorstringUpstream 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.