From scraping to decision: 19 court adapters behind one API
CourtIQ is my personal SaaS project for monitoring Brazilian judicial proceedings. It is not deployed. Everything below was designed, built and validated in a controlled environment.
Context
Brazilian judicial proceedings are public, but they are not accessible. Each court runs its own portal, with its own layout, its own session handling and its own idea of what a case record contains. A lawyer tracking twenty cases across four states opens four different websites and reads four different vocabularies.
CourtIQ is the product I wanted to exist when I was writing a judicial scraper at Agiliza Doutor. I designed it as a single API that answers one question — what changed in this proceeding — regardless of which court holds it.
The problem
There is no unified API. Two portal families cover most of the territory, PJe and e-SAJ, and it is tempting to treat each family as one integration. That is wrong. A family is a resemblance, not a contract. Markup differs per state, pagination differs, authentication and rate limits differ, and the same field carries a different label and occasionally a different meaning.
Writing one scraper per court solves the extraction problem and creates a worse one: nineteen codebases that drift apart, each with its own shape of output, and a consumer that has to know which court it is talking to. The real work is not fetching pages. It is deciding what a proceeding is, once, and forcing every court to speak it.
Constraints
Architecture
The system is a monolithic FastAPI application with a queue in front of the workers. A client authenticates with an API key, registers the proceedings it cares about, and receives changes by webhook. Nothing is scraped during a request.
The adapter layer is where the heterogeneity is contained. Each court has an adapter that knows exactly one thing: how to turn that portal's pages into the domain model. Adapters are registered rather than hardcoded, so the pipeline downstream — normalization, persistence, change detection, notification — never learns which court produced the data.
Key decisions and trade-offs
Four decisions shaped the system, and each of them cost something.
Implementation
The API core is about 20,700 lines of Python across 185 files: domain model, adapter registry, workers, multi-tenant organizations, API keys, webhook delivery with retry history, SDKs and billing. The portal is a separate TypeScript surface of about 32,800 lines across 190 files, covering onboarding, proceeding management, key issuance and delivery logs.
Multi-tenancy is in the data model from the first migration rather than added later. Retrofitting tenant isolation onto a schema that assumed one customer is the kind of migration that is never clean, and the cost of doing it up front is a mandatory organization column and a slightly noisier query layer.
Quality and tests
Consolidated validation runs 634 tests: 504 for the API at 66.42% line coverage, 41 for the scraper at 73.45%, and 89 for the frontend. Adapters are tested against saved page fixtures, so a parsing regression is caught without hitting a live court.
Coverage is the number I trust least. Line coverage says my code ran; it says nothing about a court silently renaming a field, which is the failure mode that actually matters here. The honest test for this system is a scheduled run against real portals with alerting on shape changes, and that is not something a test suite can substitute for.
Results
What was demonstrated: 19 court integrations normalized into one domain model, an asynchronous pipeline that isolates the API from unreliable upstreams, and webhook delivery with retry history, all validated in a controlled environment against fixtures and test tenants.
What was not demonstrated: anything about behaviour under real load, real proceeding volume or real customers. The project is not deployed, so there are no uptime, volume or revenue figures — and I would rather show the architecture than borrow numbers I did not measure.
Retrospective
I would invest earlier in adapter contract tests — a single shared suite every adapter must satisfy, so a new court is finished when it passes rather than when it looks right. I would also build the shape-change alarm before the billing code: for a scraping product, detecting that a portal moved is more valuable than anything downstream of it.
The decision I would repeat is the adapter boundary. It is the reason the nineteenth integration cost roughly what the third did, and the reason the domain model survived every portal I pointed it at.