◆ Solution 06 · Records access & browser automation

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.

🏛️ 4 county portals 🎭 Playwright automation 📘 OpenAPI 3.0 + Swagger UI 🔒 Read-only by design
The problem

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.

The fix

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.

STEP 01Validate twice

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.

STEP 02Route to an adapter

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.

STEP 03Resolve the folio

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.

STEP 04Drive the portal

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.

STEP 05Normalize and answer

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.

Coverage

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.

CountySearch byAnswered by
BrowardnameTax Collector
addressTax Collector
accountTax Collector
parcel / folioProperty Appraiser
Miami-Dadename / ownerProperty Appraiser
addressProperty Appraiser → Tax Collector
subdivisionProperty Appraiser
folioProperty Appraiser and Tax Collector
accountTax Collector
Adding a county is a checklist, not a rewrite. A new county means a config entry, an adapter extending the shared base class, and a routing branch — the OpenAPI document derives its county, search-type and error-code enums from that same configuration, so the published spec cannot drift out of step with what the service actually accepts.
◆ API surface · OpenAPI 3.0

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.
TypeScriptExpress PlaywrightZodWinston
/tax-search/docs — Swagger UI
Swagger UI for the County Tax Search Gateway, showing the service description, the Search endpoint group with unified, tax and property searches, and the Meta group with health and counties endpoints.
The whole surface on one screenFive endpoints, two groups, and a description that leads with the latency and authentication caveats.
Swagger UI endpoint list: POST search, POST search slash tax and POST search slash property under Search; GET health and GET counties under Meta.
Search and MetaThree POST routes that drive a browser, two GET routes that never do.
Expanded unified search endpoint in Swagger UI with an editable JSON request body specifying county, search type, query and options.
Try it from the browserThe route description spells out the Miami-Dade two-step folio resolution and when it answers 409 instead of a record.
◆ The record contract

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.
POST /tax-search/search
# Ask
{
  "county": "broward",
  "searchType": "name",
  "query": "Sample Owner"
}

# Get back — illustrative values
{
  "requestId": "1ba6f5c9-36ad-46c6-97fe-…",
  "county": "broward",
  "status": "success",
  "matches": [{
    "sourceCounty":    "broward",
    "accountNumber":   "000000-00-0000",
    "folio":           "000000-00-0000",
    "ownerName":       "SAMPLE, ALEX R",
    "propertyAddress": "100 EXAMPLE AVE, FL 33000",
    "taxYear":         "2026",
    "billStatus":      null,
    "amountDue":       null,
    "sourceUrl":       "https://county-taxes.net/…",
    "retrievedAt":     "2026-06-22T01:00:00.000Z"
  }],
  "errors": [],
  "duration": 5432
}
/tax-search/openapi.json — TaxRecord
Swagger UI schema browser with the TaxRecord schema expanded, listing every field with its type, nullability and description.
The schema is the documentationEach field carries its own nullability and a description saying when it is populated — including which fields are null on list results.
Failure is a first-class outcome

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.

HTTPOutcomeWhat it means for the caller
200Success or partialAt least one match. Partial means one source answered and another did not.
400Your mistakeInvalid 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.
404No match foundThe search ran and the portal genuinely has nothing. Deliberately not a 200 with an empty array.
409Selection neededThe lookup matched more than one property and cannot be resolved automatically. Narrow the query or pick a folio.
500Upstream problemThe 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.
CLI — same service, no server
# 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.
Before you deploy it

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.

◆ Source-code SDK for every solution

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.