Tradewinds API

Building permit records for the counties your organization holds, as JSON over HTTPS.

The API serves the complete record set.

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.

Authentication

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.

Endpoints

Method and pathWhat it does
GET /v1/permitsQuery permits with filters and cursor pagination.
GET /v1/permits/{id}Fetch a single permit.
POST /v1/exportsRequest the whole filtered set as a gzipped file.
GET /v1/exportsList your recent export jobs.
GET /v1/exports/{id}Check an export and get its download URL.
GET /v1/openapi.yamlThe machine-readable spec. No key required.

Querying permits

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": []
  }
}

Filters

Multi-valued filters repeat a singular parameter in a query string, and use a plural array in a JSON body:

ParameterNotes
stateTwo-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.
countyRepeatable, and must be paired with state. Defaults to every county you hold.
tradeRepeatable. Matches any of the given trades.
project_scopeRepeatable. Matches any of the given scopes.
property_typeExact match.
statusExact match on the normalized status.
date_min, date_maxYYYY-MM-DD, both inclusive.
updated_sinceISO 8601 timestamp. Pair with order=updated_at.
parcel_linkedtrue or false. Omit to get both, which is the default.
orderpermit_date (default) or updated_at.
limitDefault 100, maximum 1000.
cursorOpaque 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

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.

Incremental sync

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.

Permit fields

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.

FieldNotes
idTradewinds identifier for the permit record.
stateTwo-letter state, lowercase. Qualifies county, which is not unique nationally.
countyCounty slug, for example howard or baltimore-city.
numberThe permit number as the county issued it.
record_idThe county's internal record identifier, where published.
source_urlThe county's own page for this record, where published.
permit_dateIssue date. May be null; undated records are still served.
expirationExpiration date, where the county publishes one.
updated_atWhen Tradewinds last changed this record. The sync cursor.
typeThe county's own permit type string, unmodified.
statusNormalized status.
raw_statusThe county's own status string, unmodified.
descriptionWork description as filed. May be empty.
project_nameProject name as filed, where present.
addressSite address as filed.
trade_categoriesDerived trade tags. May be empty. Tolerate new values.
property_typeDerived: residential, commercial, and similar.
project_scopeDerived: install, replace, repair, and similar.
feature_tagsDerived feature tags extracted from the description.
composite_signalsDerived higher-order signals combining several tags.
amperage_ampsElectrical service amperage, where stated.
system_kwSolar system size in kW. A string, to preserve precision.
solar_panel_countPanel count, where stated.
hvac_tonnageHVAC tonnage. A string, to preserve precision.
btuHeating or cooling BTU, where stated.
water_heater_gallonsWater heater capacity, where stated.
parcelReference to the matched parcel, or null. Not a parcel record.
contractorReference to the attributed contractor, or null.

Bulk exports

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.

Errors

Every error uses one envelope, so you write one handler:

{"error": {"code": "invalid_county", "message": "Unknown county slug: 'x'"}}
CodeHTTPMeaning
unauthorized401Missing or invalid key, or the subscription lapsed.
forbidden403The key is valid but the plan lacks that feature.
invalid_request400A parameter failed validation.
invalid_county400A county slug matches no county in the system.
invalid_format400An export format other than jsonl or csv.
not_found404No such record inside your entitlement.
export_too_large400The 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.

Versioning

These are commitments, not aspirations:

Scheduled and bulk delivery

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.

Getting a key

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.

Contact us for API access