Machine-readable US building-permit fee schedules, cited to the document that adopted them.
Every number in the output carries the jurisdiction's own source URL, the sha256 of the exact bytes it was read from, the retrieval timestamp, the adopting ordinance, the effective date, and the page and line it appears on. Fees a jurisdiction does not publish are recorded as unpublished, not estimated.
{
"item_id": "phoenix-az/residential_solar_pv/fixed_option_c",
"jurisdiction_id": "phoenix-az",
"permit_type": "residential_solar_pv",
"label": "Residential solar PV fixed fee — Option C (No Plan Review)",
"basis": "option",
"status": "published",
"amount_usd": 488.0,
"currency": "USD",
"provenance": {
"source_url": "https://www.phoenix.gov/content/dam/phoenix/pddsite/documents/impact-fees/fee-schedule.pdf",
"document_sha256": "4193e742000369ae3b6814e56c7eb2563cf18075240aeb894f335d684ff9436d",
"retrieved_at": "2026-07-31T16:50:48+00:00",
"document_title": "City of Phoenix PDD Fee Schedule",
"adopting_instrument": "Ordinance G-7465",
"code_reference": "Phoenix City Code, Chapter 9, Appendix A.2",
"approved_date": "2025-12-17",
"effective_date": "2026-01-20",
"page": 42,
"line": 23,
"matched_text": "3) Option C – No Plan Review ………………… $488 Administrative Fee and 2-\n inspections"
}
}Permit-fee numbers on the open web are mostly uncited. They get copied between directories, blog posts and AI answers until a plausible round number — "$150 for a residential mechanical permit" — has no adopting authority anywhere behind it.
Two things follow from actually reading the documents:
Most permit fees are not a number. Phoenix prices building work from a
valuation table: a piecewise base fee plus a marginal rate per $1,000 of project
valuation, "or fraction thereof". There is no flat residential mechanical or
electrical permit fee to quote. This dataset publishes the structure the
jurisdiction adopted — flat, option, valuation_tiered, per_unit,
percent_of, reference — and there is deliberately no "typical fee" column.
The absence is the answer. A row saying "Phoenix publishes no flat fee for this permit type; it is priced from Table A" is more useful, and more honest, than a fabricated figure. Those rows ship with the dataset.
pip install openpermitfeesExtraction shells out to pdftotext (poppler), because fee schedules are tables
whose meaning lives in their column geometry:
sudo apt install poppler-utils # Debian/Ubuntu
brew install poppler # macOSThe library itself has no Python dependencies. A dependency that stops building is the same outage as a parser that breaks, and this package's promise is that its output does not silently rot.
openpermitfees collect # fetch → archive → extract
openpermitfees export # jsonl, csv, coverage.json, dataset card, schema.org
openpermitfees diff # change events against the previous archived version
openpermitfees status # what the archive holdsExit codes are the interface a scheduler reads: 0 everything collected, 2 at
least one document failed (its rows are still written, marked not_fetched), 3
the collector could not run at all.
As a library:
from openpermitfees import FeeItem
from openpermitfees.calculate import fee_for_valuation
rows = [FeeItem.from_dict(json.loads(line)) for line in open("dataset/open-permit-fees.jsonl")]
table = next(r for r in rows if r.item_id == "phoenix-az/building_permit/valuation_table_a")
fee_for_valuation(table.tiers, 250_500) # -> 2512.0FeeItem.from_dict re-runs every invariant on read, so a hand-edited export
cannot smuggle in a number its citation does not contain. The dataset's promise
is checkable by its consumers, not only by its producer.
- A number carries its citation or it does not exist.
FeeItemrefuses to construct with anamount_usdunless aProvenanceaccompanies it and the quotedmatched_textfrom the source document actually contains that amount. A parser that drifts onto the wrong line raises instead of publishing. - UNKNOWN is a value, not a blank.
not_fetched,not_found_in_documentandnot_published_by_jurisdictionare three different states and never collapse into each other, or into a missing row. - The first sighting of a fee is never a change. The change feed emits
first_observedat a row's t0. Dating a fee to the day we started collecting would be a claim about us, not about the fee.
SCHEMA_VERSION is 1.0.0 and frozen: consumers cache field names, and schema
churn evicts a dataset from the configs it took months to get into. Additive
changes bump the minor; renames and removals do not happen inside a major.
Jurisdictions overwrite their fee schedules in place — last year's PDF usually
stops existing at its URL. data/archive/ keeps every observed version,
content-addressed, with an append-only manifest that records every retrieval
including the ones that found nothing new (a repeated digest is the evidence
that the document did not change).
That is what makes "what did this city charge in March, and when did it change?" answerable at all, and it is only answerable for the window actually observed. This archive starts 2026-07-31.
Small and deliberately so. Each jurisdiction gets a purpose-written extractor checked against the source document — including, where the document prints one, its own worked fee calculation as a ground-truth oracle. Phoenix prints a $250,500 example totalling $2,512; the test suite recomputes it from the extracted tiers.
Generic "find the dollar signs" scraping is how a residential mechanical permit ends up priced at a sign-inspection fee. Ten jurisdictions parsed impeccably beat a hundred parsed statistically: one wrong published number costs more credibility than ninety missing ones.
See dataset/coverage.json for the current counts by status, and
dataset/DATASET_CARD.md for the generated card.
- Add
openpermitfees/jurisdictions/<id>.jsonwith the fee-schedule document URL and where you found it. - Write an extractor in
openpermitfees/extract/<id>.pythat returnsFeeItems quoting the lines it read, andnot_found_in_documentrows for fees the document does not contain. - Add a fixture of the document's
pdftotext -layoutoutput and tests pinning the exact amounts, page and line numbers — plus the document's worked example if it prints one.
Extractors must never infer, average, or regionally estimate a number. The schema will refuse it anyway.
If a number here disagrees with the document, that is a bug and the citation is
enough to prove it: every row names the page and line to re-read. Open an issue
with the item_id and what the document says.
- Code — Apache License 2.0 (
LICENSE). - Dataset — Creative Commons Attribution 4.0 International (CC BY 4.0). Cite as: Open Permit Fees (2026). US Tech Automations. https://github.com/USTechAutomations/openpermitfees
- Underlying fee schedules — public records of the jurisdictions named in each
record's
source_url. This project archives and cites them; it claims no ownership of them.
The collector identifies itself with a contact URL, obeys robots.txt, and fails
closed on ambiguity: per RFC 9309 a 4xx robots response means "no rules,
crawling allowed", while a 5xx means "unavailable, treat as disallowed". It
retries server errors, never retries a 404, and fetches one document per
jurisdiction per run.
If you run a permitting department and want this collector to stop, or to point at a better URL, open an issue — a corrected registry entry is a one-line fix.