Shipping OCR Gateway: a document-to-text API, end to end in one day
Every reconciliation workflow I have built starts the same way: someone hands you a stack of invoices as PDFs or phone photos, and step one is turning that into text a machine can reason about. That step has no business still being manual in 2026, so I built a small service for it and shipped it live today: OCR Gateway.
Upload a PDF or image, get structured text back. Free for small documents, ₹0.50/page beyond that, nothing retained. This post is the honest build log — including the bugs a live payment found that no amount of local testing had caught.
The shape of it
Two services, split by what they’re good at:
- The OCR pipeline — a Baidu Unlimited-OCR model (a ~3.3B-parameter document-understanding VLM), served from RunPod Serverless. GPU only spins up when there’s work, billed per second.
- The gateway — a FastAPI app on the same Contabo box that already runs my ERPNext/Odoo/Tally lab. It handles uploads, pricing, Razorpay payments, and talks to the OCR backend.
Two things I was deliberate about from the start:
No retention. Every uploaded file lives in a TemporaryDirectory context manager — created, used, deleted, in that order, guaranteed by Python’s finally semantics even if processing fails partway through. Results are stored just long enough for the user to download them, then expire.
Free tier first. You can process up to 5 pages without paying anything, so the first thing anyone does with this isn’t a credit card form. Beyond that, it’s ₹0.50 a page — no subscription.
What I got wrong first
This is the part worth writing down, because none of it showed up until a real payment went through.
The systemd PATH swallowed the system binaries. I set Environment="PATH=/opt/ocr-gateway/.venv/bin" on the service to make sure uvicorn resolved cleanly — not realizing that replaces the process’s entire PATH rather than extending it. pytesseract shells out to /usr/bin/tesseract, which was no longer reachable. Every OCR request failed with a 502, fast enough that it looked like a config problem rather than a missing binary. Fix was one line: append the system paths instead of overwriting them.
A duration where Razorpay wanted a timestamp. expire_by on a payment link needs an absolute Unix timestamp. I wrote int(2 * 60 * 60) — “2 hours,” except as a bare number that’s just 7200, which Razorpay read as a timestamp from 1970 and rejected outright. This was invisible for longer than it should have been because the error-handling code called raise_for_status() before reading the response body, discarding Razorpay’s actual explanation in favor of a generic HTTP error.
Pricing below Razorpay’s own floor. ₹0.50 a page sounds simple until a document is exactly one page over the free tier — 50 paise, and Razorpay hard-rejects anything under ₹1 for INR. The fix was a min_charge_paise floor on any non-free quote, surfaced honestly in the price description (“minimum charge, 1 page”) rather than as a silent bump.
os.getenv doesn’t see .env. The webhook secret lived in .env, loaded by pydantic-settings into a Settings object — which never touches os.environ. The webhook checked os.getenv("OCR_RAZORPAY_WEBHOOK_SECRET") directly and got nothing, regardless of what was actually configured. It moved to reading the same Settings object everything else in the app already used.
The payment-return page didn’t exist. I’d set callback_url when creating each payment link, pointing at a route I referenced but never built. The first real customer-facing consequence of any of this: a paying user’s browser landing on a bare {"detail":"Not Found"} after a successful charge. The payment and the webhook had both gone through correctly — the only thing missing was telling the user that.
None of these were caught by unit tests, because none of them are unit-testable in isolation — they’re integration failures between my code and Razorpay’s actual API contract, or between systemd and a subprocess call. The only way to find them was a live transaction.
Proof it works
So I ran one — a real ₹1 payment, live mode, no shortcuts:
And the part that actually matters — the webhook fired, verified its signature, ran OCR on the file that had been waiting server-side, and the user (me) landed back on a working results page instead of a 404:
Try it
ocr.ai-agentic-enterprises.com — download the sample invoice, or upload your own. First 5 pages are free.
If you hit something broken, that’s useful information, not an inconvenience — tell me. Everything above got fixed because a real transaction found it before a real customer had to.