Building permit records for the counties your organization holds, as JSON over HTTPS.
Every permit we hold for your counties is available, including permits with no trade classification and no resolved parcel. The Tradewinds web product deliberately shows a narrower enriched view, so the API returns more rows than the app does. Counts here reconcile against what the county published.
Every request carries a bearer token. Create keys in the dashboard under Account, then API keys. The secret is shown once and cannot be recovered, so store it when you create it.
curl -H "Authorization: Bearer tw_live_<prefix>.<secret>" \
"https://tradewinds.build/v1/permits?limit=2"
A key inherits its organization's counties and data window. Revoking a key in the dashboard takes effect immediately, and a key stops working the moment a subscription lapses.
| Method and path | What it does |
|---|---|
| GET /v1/permits | Query permits with filters and cursor pagination. |
| GET /v1/permits/{id} | Fetch a single permit. |
| POST /v1/exports | Request the whole filtered set as a gzipped file. |
| GET /v1/exports | List your recent export jobs. |
| GET /v1/exports/{id} | Check an export and get its download URL. |
| GET /v1/openapi.yaml | The machine-readable spec. No key required. |
GET /v1/permits?county=howard&trade=solar&date_min=2026-01-01&limit=100
{
"data": [
{
"id": "…",
"county": "howard",
"number": "B26001272",
"permit_date": "2026-07-14",
"type": "Residential Alteration",
"status": "ISSUED",
"description": "Install 20 solar panels",
"address": "8100 MAIN ST",
"trade_categories": ["solar"],
"system_kw": "7.400",
"parcel": {"id": "…", "parcel_id": "1234567890"},
"contractor": {"id": "…", "name": "…"}
}
],
"meta": {
"next_cursor": "eyJvIjoicGVybWl0X2RhdGUi…",
"has_more": true,
"data_start": null,
"counties_omitted": []
}
}
Multi-valued filters repeat a singular parameter in a query string, and use a plural array in a JSON body:
?county=howard&county=carroll{"counties": ["howard", "carroll"]}| Parameter | Notes |
|---|---|
| state | Two-letter state, repeatable, for example md or dc. Required whenever you name a county. On its own it returns every county you hold in that state. |
| county | Repeatable, and must be paired with state. Defaults to every county you hold. |
| trade | Repeatable. Matches any of the given trades. |
| project_scope | Repeatable. Matches any of the given scopes. |
| property_type | Exact match. |
| status | Exact match on the normalized status. |
| date_min, date_max | YYYY-MM-DD, both inclusive. |
| updated_since | ISO 8601 timestamp. Pair with order=updated_at. |
| parcel_linked | true or false. Omit to get both, which is the default. |
| order | permit_date (default) or updated_at. |
| limit | Default 100, maximum 1000. |
| cursor | Opaque token from meta.next_cursor. |
County always needs a state. County names are not
unique nationally: Frederick County exists in both Maryland and
Virginia, and roughly thirty states have a Washington County. A bare
county=frederick is a 400, because guessing
which one you meant would quietly return the wrong data. Send
?state=md&county=frederick. Every permit we return
carries its own state field for the same reason.
A county slug that matches no county in the system is a
400 invalid_county, because that is a typo. An unknown
state is 400 invalid_state. A real county your
organization does not hold is dropped instead, and named in
meta.counties_omitted as state/county, so a
nightly job does not fail when we add a county you have not bought.
Pagination is cursor based. Follow meta.next_cursor until
it is null. There is no total: counting a filtered table
of this size costs a full scan on every page, and we would rather
spend that on returning rows.
Cursors are opaque and bound to the order that issued them. Do not
parse them, and do not reuse a cursor across a different
order.
To keep a local copy current, sort by updated_at and
record the highest value you have seen:
GET /v1/permits?order=updated_at&updated_since=2026-08-01T00:00:00Z&limit=1000
updated_at changes whenever we revise a record, so this
picks up corrections and enrichment as well as new permits.
parcel and contractor are references, not
records. They tell you which permits share a property or a
contractor, and can be resolved later. Decimal values are strings so
that a JSON parser cannot round them into a float; parse them with a
decimal type.
| Field | Notes |
|---|---|
| id | Tradewinds identifier for the permit record. |
| state | Two-letter state, lowercase. Qualifies county, which is not unique nationally. |
| county | County slug, for example howard or baltimore-city. |
| number | The permit number as the county issued it. |
| record_id | The county's internal record identifier, where published. |
| source_url | The county's own page for this record, where published. |
| permit_date | Issue date. May be null; undated records are still served. |
| expiration | Expiration date, where the county publishes one. |
| updated_at | When Tradewinds last changed this record. The sync cursor. |
| type | The county's own permit type string, unmodified. |
| status | Normalized status. |
| raw_status | The county's own status string, unmodified. |
| description | Work description as filed. May be empty. |
| project_name | Project name as filed, where present. |
| address | Site address as filed. |
| trade_categories | Derived trade tags. May be empty. Tolerate new values. |
| property_type | Derived: residential, commercial, and similar. |
| project_scope | Derived: install, replace, repair, and similar. |
| feature_tags | Derived feature tags extracted from the description. |
| composite_signals | Derived higher-order signals combining several tags. |
| amperage_amps | Electrical service amperage, where stated. |
| system_kw | Solar system size in kW. A string, to preserve precision. |
| solar_panel_count | Panel count, where stated. |
| hvac_tonnage | HVAC tonnage. A string, to preserve precision. |
| btu | Heating or cooling BTU, where stated. |
| water_heater_gallons | Water heater capacity, where stated. |
| parcel | Reference to the matched parcel, or null. Not a parcel record. |
| contractor | Reference to the attributed contractor, or null. |
An export delivers the entire filtered result set as one gzipped file, with no pagination. It uses the same filter vocabulary as the query endpoint, so you can prototype a query and then export exactly what you saw. Exports require the bulk export entitlement in addition to API access.
curl -X POST -H "Authorization: Bearer tw_live_<prefix>.<secret>" \
-H "Content-Type: application/json" \
-d '{"counties": ["howard"], "format": "jsonl"}' \
"https://tradewinds.build/v1/exports"
{"id": "…", "status": "queued"}
Poll the export until its status is succeeded, then
fetch download_url. The URL is short lived, so request it
again rather than storing it. Verify the file against the
sha256 we return, which is the digest of the compressed
object.
A single export is limited to 500000 rows.
A request over that returns export_too_large with the row
count it matched, so you can split the range and pull it in parts. We
reject rather than truncate: a short file you were not told about is
worse than an error.
Formats are jsonl (one permit object per line) and
csv. Both are gzipped. In CSV, parcel and
contractor flatten to their id values, since a nested
object has no sensible CSV cell.
Every export carries a manifest recording the counties included, any counties omitted, the row count, the digest, and the schema version. When a county is missing from a run, the manifest is the answer.
Every error uses one envelope, so you write one handler:
{"error": {"code": "invalid_county", "message": "Unknown county slug: 'x'"}}
| Code | HTTP | Meaning |
|---|---|---|
| unauthorized | 401 | Missing or invalid key, or the subscription lapsed. |
| forbidden | 403 | The key is valid but the plan lacks that feature. |
| invalid_request | 400 | A parameter failed validation. |
| invalid_county | 400 | A county slug matches no county in the system. |
| invalid_format | 400 | An export format other than jsonl or csv. |
| not_found | 404 | No such record inside your entitlement. |
| export_too_large | 400 | The export matches more rows than the per-export limit. |
A record outside your entitlement returns 404, not
403. A 403 would confirm the record exists, which would
leak the existence of data in counties you did not buy.
These are commitments, not aspirations:
trade_categories, project_scope, feature_tags, and composite_signals. We add classifications as coverage improves./v2. /v1 stays supported through your contract term.You do not have to poll. We also deliver on a schedule, so the data lands where you already work:
Delivery to a bucket you control, or a secure download. Tell us the counties, the history depth, and the cadence, and we will scope it.
API access is granted per organization. If your plan already includes it, create a key in the dashboard under Account, then API keys. Otherwise get in touch and we will scope the counties and history you need.