§Case study
← All projectsLensio – A Privacy-First Reader for IDs and Invoices
An AI document reader that turns photos of ID cards, passports and invoices into clean data, without storing the images or leaking personal details into logs. Built for Indonesia's data protection law; the same patterns fit GDPR and the Australian Privacy Act.
- Shipped
- Reading
- 4 min read
- Stack
- Go
- PostgreSQL
- Gemini
- Keycloak
- SpiceDB
- OpenTelemetry
- Azure Container Apps
- OpenTofu
- k6

“The interesting part is not OCR, but everything around the API.”
In plain English
You send Lensio a photo of an ID card, passport, tax card or invoice. It sends back the details as clean data (name, number, dates, amounts), checked for obvious mistakes. The photo is never saved: it lives in memory for about a second and is gone. Nothing personal ends up in the logs. Every request is tied to a key that can be switched off instantly, and one customer can never see another’s data.
That’s the same job an accounting firm, a property manager or a logistics company needs done with receipts, leases or delivery notes. The documents change; the care around them doesn’t.
The rest of this page is the technical detail, for the engineers.
📦 GitHub Repository: https://github.com/amirfaisalz/lensio
Any model can read a KTP now. What stops a company from putting that in front of customers is the rest: who can call it, how much, what happens to the image, what you see when it breaks, and how fast you can undo a bad release. Lensio is my answer to those questions, built the way I’d build it for a client.
The problem
Indonesian fintech, lending and rental apps must verify identity documents. The images are personal data under UU PDP (Law No. 27/2022), the calls come from other companies’ servers, and the upstream model is slow and sometimes down. A demo that works on one laptop solves none of that.
What I built
- Six document endpoints (
/api/v1/ocr/{ktp,sim,passport,npwp,kk,invoice}) behind a pluggableOCREngine: Gemini Flash in production, a deterministic mock for CI and load tests. - Machine and human identity, kept separate. API keys are SHA-256 hashed, scoped (
ocr:write,usage:read), revocable, and shown once. People sign in through Keycloak OIDC with HttpOnly cookies, so no tokens sit in browser storage. SpiceDB decides which tenant and project each identity can touch. - Traffic guardrails. An O(1) token bucket per identity, monthly quotas, standard
X-RateLimit-*andRetry-Afterheaders, andIdempotency-Keyreplay protection on writes. - Privacy by design. Images live in memory buffers only and never touch disk. Logs carry no personal data. Usage metering goes through a buffered channel, so it never blocks a request.
- Observability. OpenTelemetry traces correlated by
request_id, Prometheus RED metrics, Grafana dashboards, SLOs and alert rules. - Delivery. Distroless images, GitHub Actions with Gitleaks, govulncheck, gosec and Trivy, OpenTofu infrastructure on Azure Container Apps, and immutable revisions for rollback.
The lesson: green tests are not a safety net
At one point the whole suite was green, and there was still a cross-tenant read/write hole: one customer could reach another customer’s data. Coverage was high. It just wasn’t testing the boundary that mattered.
The fix wasn’t more coverage. It was a dedicated suite for the authorization boundary (tenant_isolation_test.go), which now runs on every commit. This is the first thing I check in any app built fast, AI-assisted or not.
Evidence, not adjectives
| What | Result |
|---|---|
| Load test (k6, mock OCR, isolates the platform from model latency) | 1,000 RPS sustained, P95 1.21 ms, 0 5xx |
| Database under load | Pool wait_count 0, because async metering shielded Postgres |
| Rollback | Traffic shifted to the previous revision in under 60 seconds, drilled |
| Incidents | 5 written post-mortems: provider timeout, database outage, broken deploy, regression, latency cascade |
| Test coverage | ~88.5% core backend, measured, plus the tenant-isolation suite |
| Decisions | 7 ADRs, from “why Go” to multi-instance rate-limit trade-offs |
The load test also showed where it bends: bursts from one tenant serialize on the limiter mutex (~32 ms tail). That is written down as ADR-006, with Redis as the next step, instead of hidden.
What this means for your product
The same checklist applies to any product that went from idea to users quickly:
- Who can call what, and can one tenant ever see another’s data?
- What stops a runaway client or a leaked key?
- Where does personal data go, and does it end up in logs?
- When the upstream AI is slow or down, what does your user see?
- How fast can you undo a bad release, and have you actually tried?
If you can’t answer one of those with evidence, that’s where I start.
Next case study ↓
02 / 2026
LuxeQuest
Interactive Luxury Brand Experiences

Nine campaign-style launch pages for fictional premium brands and a style quiz. Each page uses an interaction technique the others don't: GLSL oceans, audio-reactive visuals, SVG displacement loupes. All of it keyboard-accessible, reduced-motion aware and tested end to end.
- Next.js 16
- React 19
- TypeScript
- Three.js
- GLSL
- Web Audio API