Buying land in Rwanda means answering a handful of questions before anyone signs anything: where exactly is the plot, what is it zoned for, is part of it wetland, can you reach it by road, and what is it roughly worth? The answers exist, but they are spread across different maps and records. Trustplot pulls them into one search by parcel number (the UPI) and one downloadable report.
This post is about the backend that does that work: a Python API for property intelligence and preliminary land due diligence. It is not a legal service. Results support an investigation; they don’t replace one, and the API is designed to say so in its data, not just in a disclaimer.
One service, organised by domain
Trustplot’s API is a monolith, on purpose. I’m one engineer, every feature leans on the same GIS data and the same database, and one deployable is far easier to reason about than five. The boundaries live in the folder structure instead of the network:
app/
api/ HTTP routes and health endpoints
core/ settings, database, HTTP client, GIS layers, middleware
db/models/ SQLAlchemy models
service/
parcel/ fetching, caching and enrichment
zoning/ zoning profiles and regulations
valuation/ reference price calculations
risk/ parcel risk assessment
road/ mapped road proximity
site_screening/ GIS queries, spatial analysis, stored screenings
report/ report assembly, PDF layout and maps
utils/ UPI and geometry utilitiesThe stack is FastAPI and Pydantic at the edge; SQLAlchemy async sessions over asyncpg into PostgreSQL with PostGIS; Alembic for migrations; HTTPX for GIS calls; Shapely and pyproj for geometry; ReportLab and Pillow for reports.
The UPI is the front door
Everything starts from a UPI such as 1/01/06/05/499. Because a UPI contains slashes, it travels as a query parameter rather than a path segment. It is trimmed and validated as numeric segments separated by slashes; anything else is rejected with a 422 before any GIS work happens.
| Method | Endpoint | Result |
|---|---|---|
| GET | /api/v1/parcels/?upi=… | Enriched parcel JSON |
| GET | /api/v1/zoning/{zone_code} | Zoning profile |
| POST | /api/v1/site-screenings?upi=… | Run and store a screening (201) |
| GET | /api/v1/site-screenings?upi=… | Latest stored screening |
| GET | /api/v1/report?upi=… | PDF report (download=true for an attachment) |
| GET | /api/health/live · /ready | Liveness · database readiness (503 if down) |
curl --get 'http://127.0.0.1:8000/api/v1/parcels/' \
--data-urlencode 'upi=1/01/06/05/499'Being a good citizen to the GIS services
Parcel and screening data come from Kigali’s GIS datasets over ArcGIS REST. Those services aren’t mine, so the API treats them carefully:
- One shared HTTP client for the whole application, instead of a new connection per request.
- A configurable concurrency limit on outbound GIS requests per worker (six by default, between 1 and 32), so a burst of screenings can’t flood the upstream service.
- Pagination handled explicitly when a layer returns more features than one page.
- A PostgreSQL parcel cache with a refresh interval (30 days by default), plus primary and fallback sources with timestamps, so every answer says where it came from and how fresh it is.
Unknown is not zero
This is the rule the whole system is built around. When you’re helping someone decide whether to buy land, a confident wrong answer is worse than an honest gap. So missing data is never quietly turned into a reassuring number:
- If a GIS layer fails or comes back incomplete, it is marked unavailable in the sources, and the fields it feeds become unknown, not zero.
- Screening keeps the layers that succeeded when another one fails. Only when every layer fails does it give up, with a
502. - Risk assessment carries an evidence-completeness indicator, and incomplete evidence cannot produce an overall low rating.
- Screening results are
clear,revieworunknown.evidence_completeis only true when every layer was available and every component status is known. - Unrecognised slope classes are marked unknown instead of being guessed.
Reference valuation, and when to refuse
The reference value is deliberately simple: parcel area multiplied by the published transaction price for the parcel’s cell. The interesting part is when it declines to answer. If the reference data holds conflicting active prices, or prices in different currencies, the estimate is suppressed rather than averaged into something plausible-looking. It is a reference figure, not a certified valuation, and it is labelled that way.
Keeping slow work out of the way
The API is async end to end, which only pays off if nothing blocks the event loop or hogs the database. Two rules keep it honest:
- CPU-heavy work runs in worker threads: spatial calculations with Shapely and pyproj, and PDF and image rendering with ReportLab and Pillow.
- Database transactions cover only database reads and writes. GIS requests, spatial analysis and PDF rendering all happen outside them, so a slow upstream map service never holds a database connection hostage.
Screenings are records, not views
A site screening checks terrain, road access, rights-of-way, nearby infrastructure and environmental constraints. Each POST runs a fresh analysis and stores it (201); a GET returns the latest stored screening without re-running anything. Stored results keep their failed layers and evidence flags, so reading a screening later tells you exactly what was and wasn’t known when it ran.
# Run a screening (the UPI goes in the query string, not a JSON body)
curl -X POST \
'http://127.0.0.1:8000/api/v1/site-screenings?upi=1%2F01%2F06%2F05%2F499'
# Save the PDF report
curl --fail --get 'http://127.0.0.1:8000/api/v1/report' \
--data-urlencode 'upi=1/01/06/05/499' \
--data-urlencode 'download=true' \
--output trustplot-report.pdfThe report is rendered directly with ReportLab: property summary cards, parcel maps, valuation evidence, zoning, risk labels, findings, warnings and sources, with page headers and footers. The sources and warnings travel with the PDF, because that is the version people forward.
Authentication, kept minimal and fail-closed
Property endpoints are public. Accounts exist for what comes next, so authentication is deliberately small:
- Email and password registration and login, with bearer tokens and a
/meendpoint. - Passwords of 8–128 characters, stored as Argon2id hashes; emails normalised to lowercase and unique. Public responses never include the password hash.
- No default signing key. Until a strong secret is configured, login returns
503instead of silently signing tokens with something guessable. - Clear status codes:
401for bad credentials or expired tokens,409for duplicate registrations.
It doesn’t yet have refresh tokens, password resets, email verification or token revocation, and the documentation says so plainly. Knowing what a system doesn’t do is part of operating it.
Operations: make the dangerous steps explicit
- Liveness and readiness are separate:
/api/health/livesays the process is up;/api/health/readychecks the database and returns503when it isn’t reachable. - Migrations are an explicit step, never a side effect of the API starting.
- Reference-data imports are deliberate commands, because the transaction-price import replaces its table. Nothing destructive runs just because a container restarted.
- Interactive docs are off in production, and in Docker Compose the database is only reachable inside the Compose network.
What I’d tell myself at the start
- Model uncertainty as data.
unknownandevidence_completeare fields, not footnotes. It made the frontend and the PDF honest for free. - Partial success is a feature. One failing map layer shouldn’t cost the user the other six.
- Keep slow things out of transactions and off the event loop from day one; retrofitting that is painful.
- Make irreversible operations boring and explicit: migrations, imports and secrets should never happen by accident.
Trustplot is live. If you want to see these decisions from the user’s side, search a parcel and download its report.