PermitIntel.aiPermit Data Intelligence
Menu

API Reference

PermitIntel Data API

A REST API over the deepest Florida permit and contractor dataset. Authenticate with a bearer token or x-api-key header. All responses are JSON.

Authentication

Pass your key on every request. Start a free trial (the key is on screen in under a minute) or subscribe to a plan (the key is emailed after checkout).

curl "https://permitintel.ai/api/v1/contractors?license=CPC1459679" \
  -H "Authorization: Bearer pmit_your_key"

# or
curl "https://permitintel.ai/api/v1/contractors?license=CPC1459679" \
  -H "x-api-key: pmit_your_key"

Usage and limits

Plans are metered in records. A record is one object of data returned to you: one permit, one contractor, one verification result, one roof lookup, one monitor, one monitor event, one supplier account. Starter includes 50,000 records a month, Growth 100,000, Scale 300,000 — see pricing.

Things that cost norecords: writes (creating a monitor, saving a book entry, adding a rep or territory — the response is your own input echoed back), responses that return no data, the coverage endpoints /api/v1/coverage, /api/v1/roof/coverage and /api/v1/jurisdictions (the per-jurisdiction detail needs a key, but it costs nothing on any plan), and /api/v1/usage itself. Errors are never charged. An endpoint that returns grouped totals costs one record per group row it returns, not one per underlying row.

Every metered response carries the meter in its headers:

X-Records-Request:   12          # records this request cost
X-Records-Limit:     50000       # your monthly allowance (omitted if unlimited)
X-Records-Remaining: 49988       # left this month (omitted if unlimited)
X-Records-Reset:     2026-10-01T00:00:00.000Z
X-RateLimit-Limit:   60          # requests per minute
X-RateLimit-Remaining: 59

There is no overage charge on any plan. When the monthly allowance is spent, requests return 402 Payment Required until the plan changes or the month rolls over. Nothing is billed and nothing is invoiced after the fact.

HTTP/1.1 402 Payment Required

{
  "error": "records_limit_exceeded",
  "limit": 50000,
  "used": 50000,
  "resets_at": "2026-10-01T00:00:00.000Z",
  "upgrade_url": "https://permitintel.ai/pricing"
}

Going too fast returns 429 with Retry-After; that is the rate limit and is separate from the records meter. A 429 costs no records.

A search can also be stopped on our side. 503 search_timeout means the query was cancelled at a server budget rather than answered incorrectly; it is transient and the same request usually succeeds. Retry after the seconds given in Retry-After. The body names the mode that was expensive, stopped_after_ms, retryable: true, and a hint for what to narrow. A 503 is not a bad request and costs no records.

{
  "error": "search_timeout",
  "message": "This search did not complete within the 8s server budget and was stopped rather than left to run. It is retryable -- the same request usually succeeds. Narrow it with a second word (\"SMITH ROOFING\" is selective where \"ROOFING\" is not), add a jurisdiction or date filter, or search by license number for an exact history.",
  "mode": "contractor_name",
  "stopped_after_ms": 8000,
  "retryable": true
}

503 upstream_unavailable means our data layer was briefly unreachable — a restart or a connection reset on the storage side, not a fault in your request. The body carries retryable: true and how many attempts we made before giving up, and the response carries Retry-After. Any endpoint can return it. It costs no records.

{
  "error": "upstream_unavailable",
  "message": "Our data layer is temporarily unreachable. This is not a problem with your request. Retry after the interval in the Retry-After header.",
  "retryable": true,
  "attempts": 2
}

500 query_failed means something is genuinely wrong on our side — please tell us. It is the one 5xx we do not mark retryable, so if you see it, retrying will not help and we want to hear about it. Our own uptime record, including every incident we have logged, is public at /status and as JSON at /api/v1/status — no key required, and it reads no database, so it still answers when the API does not. A broad contractor-name search that we could only answer in part comes back 200 instead, with search_meta.resolved_complete: false and a search_meta.resolved_reason saying why. We never return an empty 200 to stand in for a search we could not finish.

GET /api/v1/usage

What this key has spent this month. Costs 0 records and keeps answering after the allowance is spent, so you can always see why a 402 happened and when it clears.

curl "https://permitintel.ai/api/v1/usage" -H "x-api-key: pmit_your_key"

{
  "tier": "starter",
  "period_start": "2026-09-01T00:00:00.000Z",
  "period_end":   "2026-10-01T00:00:00.000Z",
  "resets_at":    "2026-10-01T00:00:00.000Z",
  "records": { "used": 12480, "limit": 50000, "remaining": 37520 },
  "requests_per_minute": { "limit": 60 },
  "overage": "none",
  "upgrade_url": "https://permitintel.ai/pricing"
}

MCP server

The same API, exposed over the Model Context Protocol so an agent can call it with no glue code. JSON-RPC 2.0 over Streamable HTTP at https://permitintel.ai/api/mcp, protocol version 2025-06-18. The manifest is at /.well-known/mcp.json.

It is a wrapper, not a second product: each tool calls the same handler the REST route calls, with your key, so authentication, rate limits and the records meter behave identically. A tool call costs exactly what the equivalent REST call costs, and the response carries the same X-Records-Request and X-Records-Remaining headers. tools/list works without a key so an agent can discover the surface first; every tools/call needs one.

Claude Desktop or Cursor

{
  "mcpServers": {
    "permitintel": {
      "type": "http",
      "url": "https://permitintel.ai/api/mcp",
      "headers": { "Authorization": "Bearer pmit_your_key" }
    }
  }
}

Claude Code

claude mcp add --transport http permitintel https://permitintel.ai/api/mcp \
  --header "Authorization: Bearer pmit_your_key"

Tools

  • verify_license — DBPR license status, type, expiry and qualifier, by license number or by business name and county. Wraps GET /api/v1/verify/license. 1 record per hit, 0 on a miss.
  • search_permits — permits by address, by contractor, or as the updated_since change feed, with every documented filter. Wraps GET /api/v1/permits. 1 record per permit returned, up to the limit cap of 200.
  • lookup_contractor — contractor profile with permit activity, contact, workers-compensation signal and certifications, by license, name, specialty or activity rank. Wraps GET /api/v1/contractors. 1 record per contractor returned, up to the limit cap of 100.
  • roof_lookup — roof age, last roof permit and material with source and confidence for one address. Wraps GET /api/v1/roof/{address}. 1 record per match, 0 on a miss.

Every tool description states its cost in records and carries the same floor-and-scope caveat the REST response carries, so a model summarising an answer has the honest wording in front of it. Counts are what we have ingested — read them as "at least N on record", never as a total filed.

Endpoints

GET /api/v1/permits

Permits by address, by contractor, by jurisdiction (browse, newest first), or as a change feed by updated_since. Filter by trade, work_type, status, county, min_value, and date range. Add include=contractor_contact to nest the licensed contractor's business name, phone, email, and verified website.

Every parameter is listed with the modes it applies in, and that column is enforced: a parameter sent outside its modes comes back 400 filter_unsupported_in_mode with an example_url that works, and a parameter name we do not recognize comes back 400 unknown_param. Nothing is accepted and quietly ignored, so a typo in your URL can never look like a gap in our data.

jurisdiction is case-insensitive. Florida jurisdiction codes are upper-case and prefixed (FL-ORANGE-ORLANDO); jurisdictions ingested outside Florida are bare and lower-case (san_francisco). Either spelling resolves, and the query.jurisdiction in the response tells you which code answered.

The code format is STATE-COUNTY-PLACE, upper-case, with underscores inside a multi-word name and hyphens only between the three segments: CA-CONTRA_COSTA-WALNUT_CREEK, FL-MIAMI_DADE-MIAMI_BEACH. A county-level code is two segments (NC-ALAMANCE). There is no CITY_OF_ prefix, no ZIP in the place segment, and a hyphen inside a name (WALNUT-CREEK) is not a code we hold. Do not guess: GET /api/v1/jurisdictions lists every code with its permit counts. Send your key for the codes themselves; without one you get the counts. It costs no records either way, and takes state, county and q to narrow.

If a well-formed code returns nothing, the response carries a did_you_mean block naming the codes we dohold in that county, most records first — and if the county segment itself is misspelled, it names the closest county code we publish. An empty page never has to be read as “no permits there”.

curl "https://permitintel.ai/api/v1/permits?contractor=CCC1335488" \
  -H "Authorization: Bearer pmit_your_key"

Parameters

ParameterModesDescription
addressaddressStreet address, prefix-matched. Minimum 4 characters.
contractorcontractorContractor business name (min 3 chars) or an exact license number.
updated_sincefeedISO 8601 timestamp. Returns every permit whose record changed at or after it, oldest change first. This is the polling parameter: unlike from/to it sees a permit backfilled today with a 2019 issue date.
new_onlyfeedWith updated_since, restrict to permits first ingested since that timestamp instead of every record that changed.
cursorfeedOpaque cursor from meta.next_cursor. Pass it back to get the next page. Stable across inserts; prefer it to offset.
statefeedTwo-letter state code. Feed mode only -- see the note under Polling.
countyfeed, address, contractorCounty jurisdiction prefix, e.g. FL-MIAMI_DADE. Matches every jurisdiction under it. Narrows another query; it cannot browse on its own, because a prefix range cannot be walked in date order.
jurisdictionfeed, browse, address, contractorExact jurisdiction code, e.g. FL-ORANGE-ORLANDO. Case-insensitive: Florida slugs are stored upper-case and nationally-ingested ones lower-case, and either spelling resolves. On its own it selects browse mode.
tradefeed, browse, address, contractorOne or more trade slugs, comma-separated. Matched against our permit classifier for roofing, hvac, electrical, pool, solar, windows_doors and fence; matched on keywords over permit_type, work_class and type_of_work for general, plumbing and demolition. Every trade-filtered response reports which matcher ran as trade_source. In browse mode the search is bounded to a 180-day window, reported as a top-level trade_window object on the response (not under meta). Feed mode applies trade with no window, but always matches it on keywords -- see trade_source.
trade_confidencebrowse, address, contractorany (default) or high. Applies only to classifier-matched trades. 'high' keeps trade signals found in the permit's type/class fields and drops those found only in its description — narrower and more precise, and zero rows in jurisdictions whose only signal is the description. Not available in feed mode, which matches every trade on keywords.
trade_matchbrowse, address, contractorclassifier (default) or keywords. 'keywords' pins the request to the pre-2026-09-30 substring matcher for every requested trade. Not available in feed mode, which always matches on keywords and reports so in trade_source.
work_typefeed, browse, address, contractorRaw substring match on the source's own work wording. Use trade for a normalized filter.
statusfeed, browse, address, contractorSubstring match on the source's permit status.
min_valuefeed, browse, address, contractorMinimum declared permit value in USD.
fromfeed, browse, address, contractorissue_date on or after YYYY-MM-DD.
tofeed, browse, address, contractorissue_date on or before YYYY-MM-DD.
citybrowse, address, contractorNarrow the result to a city. Substring match. Not available in feed mode, which is ordered on a table that does not carry the column.
zipbrowse, address, contractorNarrow the result to a 5-digit ZIP. Not available in feed mode, which is ordered on a table that does not carry the column.
includefeed, browse, address, contractorComma-separated extras. contractor_contact nests the licensed contractor's business name, phone, email and verified website.
limitfeed, browse, address, contractorRows per page. Default 50, maximum 200.
offsetfeed, browse, address, contractorOffset paging, kept for back-compatibility. Use cursor in feed mode.

Browse a jurisdiction

A jurisdictionon its own returns that jurisdiction's permits, most recently issued first. It is the quickest way to see what we hold for a place before you have an address or a contractor name.

trade works here, bounded to a 180-day window. Ordering a whole jurisdiction by date and filtering it on free-text work descriptions cannot be bounded when nothing in that jurisdiction matches the trade — the scan runs to the end looking for rows that are not there — so the window is what keeps it inside a budget we are willing to promise. Every trade-filtered browse reports the window it searched as a top-level trade_window object on the response (next to trade_source, not under meta), including exhausted: true means you are holding every match in the window, false means the page hit its limit and there are more. Pass from and to inside a 180-day span to move the window anywhere in history. The change feed (updated_since) applies trade with no window limit, but matches it differently — see the next paragraph.

Where trade comes from. In browse, address and contractor mode, for roofing, hvac, electrical, pool, solar, windows_doors and fence, a trade is assigned by our permit classifier, which reads the permit's type and class fields and its description. For general, plumbing and demolition it is still a keyword match over permit_type, work_class and type_of_work — general has no classifier tag at all, and for the other two we measured that the keyword match is the more complete answer today. Every trade-filtered response tells you which matcher ran, in trade_source, so you never have to infer it.

The change feed matches every trade on keywords. It is the one mode that does not use the classifier, and trade_source.matcher on a feed response always reads keywords. The reason is the shape of the mode: the feed orders every changed permit by updated_at across the whole corpus, so it evaluates the trade against the permit description after the rows are read, inside a scan budget — which is also why a feed page can be short and report meta.scan_complete: false. The practical consequence is that the same ?trade=roofing can return a different set in the feed than in browse, so compare feed pages to feed pages. trade_confidence and trade_match are refused in feed mode with filter_unsupported_in_mode rather than accepted and ignored.

Two parameters control it. trade_confidence=highkeeps only trades named in the permit's type/class fields and drops those found only in the description: narrower and more precise, but zero rows in jurisdictions whose permit types are generic and whose only trade signal is the description — Los Angeles is one of them. The default, any, includes both. trade_match=keywords pins a request to the keyword matcher for every trade, which is the exact behaviour of this endpoint before 30 September 2026.

county is not available in browse mode: it is a code prefix covering many jurisdictions, and a prefix range cannot be walked in date order — the planner has to sort the whole county before it can honour a limit. Name a jurisdiction inside the county, or poll the feed, where county works.

curl "https://permitintel.ai/api/v1/permits?jurisdiction=FL-ORANGE-ORLANDO&limit=25" \
  -H "Authorization: Bearer pmit_your_key"

# lower-case, nationally-ingested jurisdiction codes work the same way
curl "https://permitintel.ai/api/v1/permits?jurisdiction=san_francisco&limit=25" \
  -H "Authorization: Bearer pmit_your_key"

# trade in browse: the newest 180 days of roofing permits in one jurisdiction
curl "https://permitintel.ai/api/v1/permits?jurisdiction=CA-CONTRA_COSTA-DANVILLE&trade=roofing&limit=100" \
  -H "Authorization: Bearer pmit_your_key"

# ...and anywhere in history, by moving the window
curl "https://permitintel.ai/api/v1/permits?jurisdiction=CA-CONTRA_COSTA-DANVILLE&trade=roofing&from=2024-01-01&to=2024-06-28" \
  -H "Authorization: Bearer pmit_your_key"

Errors

CodeHTTPMeaning
missing_query400None of address, contractor, jurisdiction or updated_since was supplied. county and trade narrow one of those; they do not select rows on their own.
unknown_param400A query parameter name this endpoint does not accept. The response lists what you sent under unknown and everything accepted under allowed.
filter_unsupported_in_mode400A real parameter, in a mode that cannot honour it — zip and city outside address/contractor/browse, cursor or new_only outside the feed, county in browse, trade_confidence and trade_match in the feed (which always matches on keywords). Carries param, mode and an example_url. trade is no longer refused in browse: it is served inside a 180-day window, reported as the top-level trade_window object.
state_unsupported_in_mode400state is feed mode only. Use jurisdiction or county elsewhere, which are exact.
unknown_jurisdiction400The jurisdiction code is a source dataset identifier rather than a place. Jurisdiction codes are places: FL-ORANGE-ORLANDO in Florida, or a bare slug such as san_francisco elsewhere. Send your key to /api/v1/coverage or /api/v1/jurisdictions and either will list every jurisdiction we hold.
browse_filter_too_slow400A browse filter that is not indexed alongside date ordering was stopped at the time bound rather than left to hang. Whether a filter is affordable depends on how many rows match it. Names the param, and carries stopped_after_ms and an example_url.
jurisdiction_lookup_failed503We could not resolve the jurisdiction code, so the filter was not applied. Transient and retryable; carries Retry-After. It is never answered with an empty page, because an empty page here would read as 'no permits in this jurisdiction'.
unknown_trade400A trade slug we do not define. The response lists the supported slugs.
unknown_trade_confidence400trade_confidence was neither 'any' nor 'high'. Refused rather than ignored, because falling back to the default would answer 200 with a different result set than the one asked for.
unknown_trade_match400trade_match was neither 'classifier' nor 'keywords'. Refused for the same reason as trade_confidence.
unknown_include400An include we do not offer.
bad_updated_since400updated_since is not an ISO 8601 timestamp or a date.
bad_date400from or to is not a real calendar date as YYYY-MM-DD. Refused rather than ignored, so a typo cannot look like a date range we hold no data for. A date-shaped value that does not exist (2026-02-30) is refused too.
bad_state400state is not a two-letter state code.
bad_county400county is not a jurisdiction prefix such as FL-MIAMI_DADE.
bad_cursor400A cursor this endpoint did not issue. Drop it to restart from updated_since.
address_too_short400address is under 4 characters. Address lookup is a prefix match.
contractor_too_short400contractor is under 3 characters and is not a licence number.
pagination_exhausted400offset + limit is past the page-depth ceiling — for a contractor-name search, or for a jurisdiction browse. Search by licence number for an exact history, or use the change feed (updated_since), which pages by cursor with no depth limit.
feed_filter_too_slow400This combination of updated_since and a filter is not indexed and was stopped rather than left to hang. Poll one jurisdiction at a time, or poll the feed with no jurisdiction filter and narrow your side. A state-wide change feed is not indexed and is refused on the same bound — use county, which is scanned per jurisdiction. A trade filter no longer produces this: it is matched after the rows are read and pages by cursor instead.
feed_filter_too_broad400The county names more jurisdictions than the change feed will scan in one request. Poll the jurisdictions individually with jurisdiction=. No county we currently hold reaches this limit.

Poll for new permits weekly

from and tofilter on the permit's issue date, so a permit we ingest today carrying a 2019 issue date never shows up in a date-window poll — and records arriving from public-records responses, rescrapes, and status refreshes are most of what lands. updated_since filters on when the recordchanged instead, which is what "everything new since my last run" actually means. Rows come back oldest-change-first so the walk is resumable; store meta.next_cursor and pass it back as cursor until it comes back null.

# 1. first run: everything changed since your last sync
curl "https://permitintel.ai/api/v1/permits?updated_since=2026-09-01T00:00:00Z\
&state=FL&trade=roofing&limit=200&include=contractor_contact" \
  -H "Authorization: Bearer pmit_your_key"

# 2. walk the pages
curl "https://permitintel.ai/api/v1/permits?updated_since=2026-09-01T00:00:00Z\
&state=FL&trade=roofing&limit=200&cursor=MjAyNi0wOS0wMVQwMDowMDowMFp8MTIz" \
  -H "Authorization: Bearer pmit_your_key"

# 3. next run: only permits FIRST INGESTED since then, not every record we touched
curl "https://permitintel.ai/api/v1/permits?updated_since=2026-09-08T00:00:00Z\
&new_only=true&state=FL&trade=roofing&limit=200" \
  -H "Authorization: Bearer pmit_your_key"

In Zapier, n8n, or Clay the same three steps are: a schedule trigger; an HTTP GET with updated_since set to the value you stored last run; a loop that re-issues the GET with cursor while meta.has_moreis true; then store the run's start time for the next trigger. Two things to build against rather than discover: a page can return fewer rows than limit and still not be the end — the feed ends when next_cursor is null, never on a short page — and without new_only this is a change feed, not a new-permit feed: it includes enrichment and status updates to permits you have already seen, which is what you want for keeping a mirror in sync and not what you want for lead alerts.

A trade filter on the feed can hand back an empty page that is not the end.A trade is matched against the permit's own work wording after the rows are read, not by an index, so each page scans a bounded window and returns whatever in it matched. Where a trade is thin on the ground, early pages come back short or empty. Those pages carry meta.scan_complete: false and a next_cursor pointing at where the scan stopped rather than at the last row served, so the walk still moves forward. Page until next_cursor is null; never stop on a zero-row page, and never read one as “no permits of this trade here”.

Jurisdiction ids are not ZIP codes. They look like CA-CONTRA_COSTA-ANTIOCH (state, county, place), or, for our national sources, a bare lowercase slug such as san_francisco. To see every id we hold inside a county, call /v1/jurisdictions?county=CA-CONTRA_COSTA; ?state= and ?q= reach the bare slugs, which carry no county prefix. A feed request under a code we hold nothing for answers 200 with a did_you_mean block and says the page is empty because the code is not one of ours, rather than leaving you to read an empty page as an empty market.

state is available on the feed only. On an address or contractor lookup, use jurisdiction (exact, e.g. FL-ORANGE-ORLANDO) or county (a prefix, e.g. FL-MIAMI_DADE), which are exact rather than approximate.

County change feeds are supported. county combined with updated_since is answered by scanning each jurisdiction inside the county and merging the results in cursor order, so one poll keeps a whole county in sync and the cursor resumes across it exactly as it does for a single jurisdiction. Every county we hold fits; a county naming more than 40 jurisdictions would come back feed_filter_too_broad rather than a quietly short page.

A state-wide change feed is the one shape we cannot serve: state is not indexed together with updated_at, so state plus updated_since is refused with feed_filter_too_slow rather than left to hang. For a state, either poll its counties, or poll the feed with no jurisdiction filter at all — that shape is fast — and filter on your side.

GET /api/v1/jurisdictions

The jurisdiction codes you can pass to jurisdiction and county, so you never have to guess one. Send your key for the codes; a keyless caller gets the counts and the filters but not the list. Free of records either way. Narrow with state (two letters), county (a code prefix such as FL-MIAMI_DADE) or q (a substring of the code), and page with limit and offset. Each row carries the code, its state and county, total permits on file, the trailing-12-month count, how many of those permits resolved to a contractor license, and when we last ingested it. Counts are a floor on what we hold, not a completeness claim, and jurisdictions whose records sit under a raw dataset identifier rather than a place code are not listed.

curl -H "x-api-key: pmit_your_key" \
  "https://permitintel.ai/api/v1/jurisdictions?county=FL-MIAMI_DADE"

A browse that returns nothing under a well-formed code answers 200 with a did_you_mean block listing the codes we do hold in the same county, most records first — so an empty page never has to be read as “no permits there”. It is not a spelling correction, and the list is not exhaustive.

curl "https://permitintel.ai/api/v1/permits?jurisdiction=FL-MIAMI_DADE-MIAMI" \
  -H "x-api-key: pmit_your_key"

GET /api/v1/permits/{id}

One permit, by the id on every row the search returns — the same object, with the contractor’s contact already nested rather than behind include. The key is the internal id, not the permit number: permit offices number independently, so one permit number names different buildings in different jurisdictions and cannot identify a single record. Send a permit number and you get 400 bad_permit_id saying so, never a 404. A permit we hold but do not serve answers 404 — the same records the search would never return.

curl "https://permitintel.ai/api/v1/permits/236705358" \
  -H "x-api-key: pmit_your_key"

GET /api/v1/contractors

A contractor by license, or search by name or specialty. Every result carries a permit-activity rollup: permit count, declared value, 12-month trend, top trades, top jurisdictions, and last-active date, plus verified workers-comp and EPA lead-safe signals and canonical contact.

curl "https://permitintel.ai/api/v1/contractors?specialty=roofing&county=Lee" \
  -H "x-api-key: pmit_your_key"

Rank a market by activity. Add sort (permits_12mo, permits, value_12mo, value, last_active) and floor it with min_permits or min_permits_12mo. This is the supplier and sales-intelligence workflow: the busiest roofers in a county, ranked, ready to call.

curl "https://permitintel.ai/api/v1/contractors?specialty=roofing&county=Lee&sort=permits_12mo&min_permits_12mo=5" \
  -H "x-api-key: pmit_your_key"

GET /api/v1/contractors/{license}

One contractor profile by DBPR license number — exactly what ?license= returns, plus links.permits (their full permit history URL) and recent_permits, their ten most recent permits in the same row shape the permit search uses. The license number is the identity: there is no contractor UUID, because every join in the product is keyed on the license. recent_permits is a sample, never a history — activity.permit_count is the count on record and links.permits is the paged history.

curl "https://permitintel.ai/api/v1/contractors/CGC1524985" \
  -H "x-api-key: pmit_your_key"

GET /api/v1/verify/license

The trust primitive. An authoritative DBPR license check by license (or name plus county), refreshed daily. Returns status, type, effective and expiration dates, the qualifier, and a computed is_active and expires_in_days. Available on every plan, including the free trial.

curl "https://permitintel.ai/api/v1/verify/license?license=CCC1331234" \
  -H "Authorization: Bearer pmit_your_key"

GET /api/v1/verify/insurance

Workers-comp coverage status and lapse intelligence for a licensed business, from Florida DWC and Texas DWC proof-of-coverage and California CSLB records. Returns wc_status (active, expired, cancelled, or not_found), carrier, and policy dates, plus a coverage array carrying general liability and surety bond where the state publishes them (WA L&I, OR CCB). A not_found result means we hold no matched workers-comp record for that license, not that the business is uninsured. Pass &state= when a bare license number exists in more than one state. B2B verification of a business, not consumer underwriting. Starter and up.

curl "https://permitintel.ai/api/v1/verify/insurance?license=CCC1331234" \
  -H "x-api-key: pmit_your_key"

GET /api/v1/verify/certifications

Compliance badges in one call: EPA RRP lead-safe certification and Florida minority or disadvantaged business designation, matched to the license. Starter and up.

curl "https://permitintel.ai/api/v1/verify/certifications?license=CCC1331234" \
  -H "x-api-key: pmit_your_key"

POST /api/v1/monitors

Turn a one-time lookup into an always-on subscription. Watch a license for a license lapse, an insurance lapse, a new permit, or a certification expiry, and get a webhook or poll for events. Built for insurers, lenders, GC networks, and marketplaces continuously verifying a roster. Growth and up.

To subscribe, POST to /api/v1/monitors with Content-Type: application/json. The body carries license, an events array drawn from license_lapse, insurance_lapse, new_permit and certification_expiry, and an optional webhook_url. Leave the webhook off and poll the events endpoint instead.

# list yours
curl "https://permitintel.ai/api/v1/monitors" -H "x-api-key: pmit_your_key"

# poll events (no webhook needed)
curl "https://permitintel.ai/api/v1/monitors/<id>/events?since=2026-06-01" -H "x-api-key: pmit_your_key"

# stop watching
curl -X DELETE "https://permitintel.ai/api/v1/monitors/<id>" -H "x-api-key: pmit_your_key"

Webhook deliveries are signed X-PermitIntel-Signature: sha256=HMAC(secret, body) with a per-monitor secret returned once at creation.

GET /api/v1/supply/accounts

Roll every Florida contractor up into a supplier sales account: demand by trade, momentum (90 days versus the prior 90), an activity grade A to D, a territory rank, serviceability (canonical email, phone, and workers-comp on record), and contact depth: every phone and email on record for the business, ranked by how often permits, county registries, and the state licensing board carried it, each with its sources and last-seen date, plus the website and filed address. Government mailboxes, permit expediters, placeholders, and numbers shared across unrelated businesses are filtered out before you see the list. Filter by county, trade, minimum 12-month permits, momentum band, and grade; sort by value, permit count, momentum, or roofing activity. Add format=csv to hand a rep the whole territory. Growth and up.

curl "https://permitintel.ai/api/v1/supply/accounts?county=Broward&trade=roofing&min_permits_12mo=5&momentum=growing&grade=A&sort=value_12mo" \
  -H "x-api-key: pmit_your_key"

# whole territory as a spreadsheet
curl "https://permitintel.ai/api/v1/supply/accounts?county=Broward&trade=roofing&format=csv" \
  -H "x-api-key: pmit_your_key"

Demand figures are permit-activity proxies, a floor and not a complete record, and the activity grade is a sales-prioritization signal, not a credit score.

Supplier workspace (book, reps, territories)

Turn the scored-account feed into a working book of business: save accounts with a pipeline status, assign them to reps, store reusable territory filters, and put any account on a watch. A watched account reuses the monitoring engine, so a new permit, a license lapse, or an insurance lapse fires the same poll or signed webhook. Growth and up.

All three write endpoints take Content-Type: application/json. A rep needs name and email. A book entry needs license and status, and optionally rep_id, watch, and webhook_url to put the account on the monitoring engine. A territory needs name plus whichever account filters you want stored: county, trade, min_permits_12mo, momentum, grade, and rep_id. PATCH the book with a license and the new status to move an account down the pipeline.

# list the book (each row enriched with live demand, momentum, grade)
curl "https://permitintel.ai/api/v1/supply/book?status=contacted" -H "x-api-key: pmit_your_key"

Pipeline statuses: prospect, contacted, active, won, lost. Resolve a saved territory by passing its fields to /api/v1/supply/accounts for the live ranked list.

GET /api/v1/roof/{address}

Roof state for one property: roof_age_years (derived from the most recent roof permit on record), roof_last_permit_date, and roof_material with source and confidence. Add ?zip= to disambiguate. Every response carries a per-jurisdiction coverage_grade (A, B, or C) so a miss reads as not covered, never as no roof work. On every plan, including the trial.

curl "https://permitintel.ai/api/v1/roof/2082%20HYDE%20PARK%20ST?zip=34239" \
  -H "Authorization: Bearer pmit_your_key"

GET /api/v1/roof/{address}/evidence

The citation layer behind a roof answer: the underlying roof permit record(s) with permit number, jurisdiction, issue date, contractor and license, declared value, and status. Proof an underwriter or inspector can file. On every plan.

curl "https://permitintel.ai/api/v1/roof/2082%20HYDE%20PARK%20ST/evidence?zip=34239" \
  -H "x-api-key: pmit_your_key"

POST /api/v1/roof/batch

Score a book of business: up to 1,000 addresses per request, returned in input order with match status, roof age, material, and jurisdiction. Loop requests for bigger books. Growth and up.

POST with Content-Type: application/json and a body containing an items array. Each entry carries an address and an optional zip to disambiguate.

GET /api/v1/roof/coverage

The coverage table behind the grades. Without a key you get the totals and the A/B/C histogram, filterable by state, county, or grade. Send your key for the per-jurisdiction rows: property counts, roof-age fill rate, material fill rate, data freshness, and the grade. The detail costs no records on any plan.

curl -H "Authorization: Bearer $PERMITINTEL_API_KEY" \
  "https://permitintel.ai/api/v1/roof/coverage?state=FL&grade=A"

Scheduled delivery, warehouse loads and GIS layers

Supplier Portal Statewide accounts can have accounts, permits and market rollups pushed nightly to their own SFTP server, S3 bucket or Cloud Storage bucket as CSV or Parquet, pull a signed file feed, or add their accounts to ArcGIS Online as a GeoJSON layer. Delivery guide with Snowflake, BigQuery, Databricks and ArcGIS recipes.

Permit data reflects records PermitIntel has ingested and is not represented as a complete record of all permits. Contractor data is public record; no disciplinary or negative information is exposed via the API.