A schema check at the API boundary for shape, enums and ranges — then a second check that this search type is actually valid for that county. A bad request is rejected before a browser is ever launched.
Some records only exist behind a browser.
County tax collectors and property appraisers publish the numbers that decide a deal — what is owed, who owns it, whether a bill is delinquent — through JavaScript portals with no public API. This service drives a real browser against those portals and hands your application one normalized record over REST.
There is no endpoint to call
The data is public and the portals are free to use. Getting it into software is the part nobody has solved for you.
No public API
These portals were built for a person with a mouse. There is no documented endpoint, no key to request, and nobody to ask for one.
JavaScript all the way down
They are single-page applications. Fetching the URL returns an empty shell — the records only appear after scripts run and a search is submitted.
Every county is different
Different search modes, different field names, different flows. Two counties means two integrations that share nothing.
They push back
Bot verification, rate limiting and periodic redesigns. Anything naive breaks quietly and you find out from a customer.
One request in, one record shape out
Your application posts a county, a search type and a query. Everything between that and a normalized record is the gateway's problem.
County plus search type selects the tax collector or property appraiser adapter. Broward sends names and addresses to the tax side and parcels to the appraiser; Miami-Dade routes most types through the appraiser.
Miami-Dade tax records are keyed by folio, so an address or name search queries the Property Appraiser first, then re-queries the Tax Collector with the folio it found.
A Chromium instance navigates, handles the verification interstitial, selects the search mode, submits and parses — with retries on exponential backoff and a screenshot captured on failure.
Results become one record type with every field explicitly nullable, wrapped in an envelope carrying a request ID, a status, the matches, any errors and the elapsed duration.
Two counties, four portals
Both sides of each county — what the appraiser says the property is, and what the collector says is owed on it.
| County | Search by | Answered by |
|---|---|---|
| Broward | name | Tax Collector |
address | Tax Collector | |
account | Tax Collector | |
parcel / folio | Property Appraiser | |
| Miami-Dade | name / owner | Property Appraiser |
address | Property Appraiser → Tax Collector | |
subdivision | Property Appraiser | |
folio | Property Appraiser and Tax Collector | |
account | Tax Collector |
Documented, executable, honest about latency
Swagger UI ships with the service and works offline — every endpoint browsable and runnable from the browser, with the raw spec available for client generation.
- Search — a unified endpoint plus tax-only and property-only variants, all POST, all driving a live browser.
- Meta — health check and a capability-discovery endpoint listing supported counties and what each one can answer.
- One envelope — every outcome except schema and body-parse failures returns the same result shape, with a request ID that correlates to the server logs.
- Stated up front — the spec's own description warns that requests take 10–70 seconds and that there is no authentication layer, rather than burying it.
Every field nullable, on purpose
A list result from a county portal carries less than a detail page does. Rather than omitting fields and making callers guess, every field is present and explicitly null when the portal did not supply it.
- Null means "the portal did not say" — not "zero", and not "we forgot to parse it". Amounts and bill status are commonly null on list results and populated on a folio lookup.
- Provenance on every record — the source portal URL and an ISO 8601 retrieval timestamp, so a number in your system can always be traced back and dated.
- Raw payload on request — the unparsed source data is available behind an option when you need to audit what the parser saw.
- Two record types — tax bill records from the collector and property records from the appraiser, each with its own schema in the spec.
The status code tells you whose problem it is
Driving someone else's website goes wrong in specific, recurring ways. Each one maps to a distinct code and a named error, so your retry logic can be written once and be correct.
| HTTP | Outcome | What it means for the caller |
|---|---|---|
200 | Success or partial | At least one match. Partial means one source answered and another did not. |
400 | Your mistake | Invalid input, an unsupported county, or a search type that county cannot answer — caught before any browser launches. Malformed JSON lands here too, not in a 500. |
404 | No match found | The search ran and the portal genuinely has nothing. Deliberately not a 200 with an empty array. |
409 | Selection needed | The lookup matched more than one property and cannot be resolved automatically. Narrow the query or pick a folio. |
500 | Upstream problem | The portal timed out, blocked us, demanded verification, or changed its layout. Yours to retry, not to fix. |
- Named error codes, not prose — bot verification required, portal layout changed, portal timeout, portal blocked, restricted or confidential record, and more, each distinguishable in your logs.
- Confidential records are their own outcome. Some records are legally restricted; that is reported as such rather than surfacing as an empty result.
- Screenshots on failure — a full-page capture is written for every error, which is usually the fastest way to see that a portal redesigned overnight.
- Structured logs keyed by request ID, so the ID in a response leads straight to the trace behind it.
# Broward tax records by owner name npm run broward:tax -- --mode name \ --query "Sample Owner" # Miami-Dade property records by folio npm run miami:pa -- --mode folio \ --query "01-2345-678-9000" # Watch it work — headed browser npm run broward:tax -- --mode name \ --query "Sample Owner" --headed # Human-readable instead of JSON npm run broward:pa -- --mode address \ --query "123 Main St" --format text # Exit code 0 on success, 1 on anything else, # so it drops straight into a shell pipeline.
What this is, and what it is not
A browser-automation gateway has real constraints. They are cheaper to know now than to discover in production.
Read-only, always
The service reads published records. It never writes, never submits a payment and never authenticates as anyone. There is no code path that could change a county's records.
Seconds, not milliseconds
Each request launches a browser and waits on a live portal — typically 10 to 70 seconds. Treat it as a background job with generous client timeouts, not a synchronous lookup in a request path.
Put auth in front of it
The service ships with no authentication — deliberately, so it composes behind whatever you already use. Add an API key or OAuth layer before it is reachable outside a trusted network.
It cannot solve a CAPTCHA
A verification interstitial is handled where possible and reported as a named error where not. Nothing here attempts to defeat a challenge that a county has deliberately put in the way.
Be a good neighbour
County infrastructure is not built for volume. Rate-limit your own use of the gateway; the retry policy backs off rather than hammering, and sessions are torn down after every request.
Portals will change
Adapters try a list of candidate selectors and fall back to extracting from raw HTML, so a small redesign usually degrades rather than breaks — but a large one needs an adapter update.
* Records retrieved through this service are public records published by the counties named above, and may include owner names and property addresses. Handling them is subject to your own obligations — the service logs are structured to make redacting personal data straightforward, and sample values shown on this page are fictional.
The tax side of a parcel
The other solutions answer what a parcel is and what can be built on it. This one answers what is owed on it.
Beside parcel federation
The proxy federates counties that publish a service. This one covers counties that publish only a website — the same normalization problem, a very different transport.
Solution detail →Into the pipeline
Delinquency and amounts owed are a distress signal the composite opportunity score wants — the kind of thing that separates a motivated owner from a merely underbuilt lot.
Solution detail →Into due diligence
Tax status with a source URL and a retrieval timestamp attached is exactly what a workspace needs on file when a deal moves from interesting to underwritten.
Solution detail →Tell us which counties you need
Broward and Miami-Dade are covered today. Adding a county is an adapter and a config entry — tell us where you work and we will scope it honestly, including the portals that will fight back. The service is available as source, so your team can add counties without us.