The AI Reads, the Code Checks: Building an Invoice Reader You Can Actually Trust

Six values, typed by hand, over and over
Every credit note or invoice a supplier sends ends up in front of someone who has to type six things into a system: the document number, the document type, the date, the total amount, the supplier’s GSTIN and the buyer’s GSTIN.
Nobody enjoys this work. It’s slow, it’s repetitive, and sooner or later a tired person types an 8 where the PDF says 3.
Language models can now read a PDF and return those six values in a few seconds. So the obvious plan is to send the PDF to a model and save whatever it returns. We almost did exactly that. One uncomfortable fact stopped us:
When an AI gets something wrong, the wrong answer looks exactly like a right one.
A misread GSTIN still has 15 characters. An invented invoice number still looks like an invoice number. A total with one wrong digit is still a perfectly believable amount. If we stored the model’s output as-is, we wouldn’t be removing mistakes. We’d be moving them from a person’s keyboard to a model, where they are much harder to spot.
One rule: the AI reads, the code checks
So we built the whole system around a single rule. The model is only allowed to propose values. Every value it proposes then has to survive a set of checks written in plain code.
Those checks don’t use AI. They cost nothing to run, they give the same answer every time, and each one can explain why it failed.
Every document ends up in one of two states:
- ACCEPTED: every check passed.
- NEEDS REVIEW: at least one check failed, and the result names it. For example, “Buyer GSTIN check character is wrong” or “Total doesn’t match sub-total + tax”.
We chose not to show an AI “confidence score”. If a model says it’s 93% sure, the person reviewing the document still has no idea where to look. A failed check called TOTAL_MATCHES_BREAKDOWN tells them exactly where to look, and they can usually settle it in seconds.
The pipeline at a glance
Here is the path a single PDF takes, from link to verdict:
- Download the file safely and reject anything that isn’t a usable PDF. (free)
- Pick the first and last page.
- Decide, page by page, whether it’s digital or scanned.
- Rebuild the text layout, render page images and read any QR codes. (free)
- Make one AI call with the page images, the page text, the instructions and a JSON schema. (paid)
- Run every check in code. (free)
- Return ACCEPTED or NEEDS REVIEW, with reasons.
Two decisions shaped everything else.
Everything cheap runs before the AI. A broken link, an HTML error page pretending to be a PDF, a password-protected file, an account with no AI key: all of these are caught before a single token is paid for. A bad request costs nothing.
Exactly one AI call per document. No retries, and no second model pass to double-check the first. Checking is the code’s job. This also makes cost predictable, because the worst case is one call, the same as the best case.
Step 1: Don’t trust the link either
Our server downloads whatever URL it’s given. That’s a well-known way to get attacked, called server-side request forgery (SSRF). Someone could hand us a link to an internal address and trick our own server into fetching things it should never touch.
So before and during every download:
- Only https links are allowed.
- Internal and private network addresses are blocked, including when a harmless-looking public link redirects to one.
- Files are capped at 10 MB, and the whole download is capped at 30 seconds.
That last rule taught us something. In many HTTP libraries, the built-in timeout doesn't limit the total download time. It only fires when the server goes completely silent. A server that drips one byte every few seconds never trips it and can hold a connection open indefinitely. So we added a hard cap on total time.
“Is this actually a PDF?” turned out to be trickier than expected too. Our first check looked for %PDF anywhere in the file. Then an HTML login page that happened to mention "%PDF" in its text sailed straight through. Now the PDF header has to sit right at the start of the file, with no HTML in front of it.
Step 2: Read less, but read it well
Only the first and last page
The header of an invoice (number, date, supplier, buyer) is almost always on page one. The totals, the amount in words, and the QR code are almost always on the last page. Everything in between is line items, which we don’t need.
Sending two pages instead of twenty is cheaper and faster. It also gives the model less to get confused by.
Digital or scanned? This matters more than anything else
There are really two kinds of PDF, and they need different treatment.
A digital PDF, exported straight from billing software, has real text inside it. We can give the model the exact characters, and later we can check that every value it returned actually appears in the document.
A scanned PDF is just a picture of paper. The model has to read pixels, and there’s no text layer we can check its answers against.
We make this call page by page, using a few signals: how much text there is, how much of the page is covered by images, and whether the text is garbage from a broken font.
One case is sneaky. Some scans carry an invisible OCR text layer, so they look digital. But that OCR text is often wrong, with O instead of 0 or 8 instead of B. If we treated it as trustworthy, wrong values could pass our checks. So we treat those pages as scanned.
Give the model both the picture and the text
For digital pages, we send the page image and the text. Each one covers the other’s weakness.
Text on its own loses the layout. It can’t tell you which of two GSTINs sits under “Supplier” and which sits under “Bill To”. An image on its own risks misread characters. Together, the model uses the image to understand where things are, and the text to copy exactly what they say.
We also rebuild the raw text into proper lines and columns using the position of each word. That way the model sees Invoice No: CN/0142 on one line instead of scattered fragments. This also handles rotated pages, oversized pages and fillable PDF forms.
QR codes: read the original, not a copy
Indian GST e-invoices carry a QR code that is digitally signed by the government’s Invoice Registration Portal (IRP). It holds, among other things, the supplier GSTIN, the buyer GSTIN, the document number, type and date, and the total invoice value. That gives us a second, independent, machine-readable source of truth.
Our first attempt decoded QR codes from the rendered page image. The problem was that the QR is an image embedded inside the PDF, and rendering the whole page shrank it.
Step 3: One AI call, with tight fences around it
The client picks the provider; we pick the model
Each client brings their own AI key from OpenAI, Anthropic or Google Gemini, so they pay for their own usage. They choose the provider. They don’t choose the model. That is fixed in code for each feature.
The reasoning is simple. The client pays, but we’re accountable for the quality of the result. If a client could quietly switch to an older or weaker model, accuracy would drop and we’d hear, “your feature is broken.” Different features can need different models, so each feature declares its own.
Structured output, and “empty means not found”
Every provider is asked to answer in a fixed JSON schema. Every field is a plain string, and an empty string means “this isn’t printed on the document”.
That one rule matters a lot. The model is never pushed into inventing a value just to fill a slot. All parsing of numbers and dates happens later, in our own code.
We also ask for a few helper values the user never sees: subtotal, CGST, SGST, IGST, other charges, round off, amount in words, and place of supply. They exist only so the checks have something to compare against.
Prompts live in the database, not in the code
Prompts change much more often than code does. We store them in a database with version numbers, cached in Redis. To change a prompt, we insert a new version and clear the cache. No deploy needed, and old versions are never edited.
Every result is logged with the prompt version that produced it. When quality shifts, we know exactly which prompt was live.
There is deliberately no fallback copy of the prompt in the code. Two copies mean one of them eventually goes stale. If no prompt can be loaded, the request fails before anything is downloaded or any AI is called.
Step 4: The checks, where trust actually comes from
This is the part that makes the output trustworthy. Every check is independent, and every check has a name.
GSTIN: the checksum catches misreads
A GSTIN has a fixed structure: a two-digit state code, the business’s 10-character PAN, an entity number, the letter Z, and a final check character. That last character is calculated from the first 14 using a Luhn mod 36 algorithm.
This is what makes it so useful. If the model misreads any single character, say an O as a 0, the check character won't match.
For each GSTIN we check:
- the overall format;
- the check character;
- that the first two digits are a real state code;
- that the middle part is a valid PAN;
- that the supplier and buyer GSTINs aren’t the same (a surprisingly common way for a model to fail).
The math has to add up
The totals must satisfy a simple equation:
sub total + CGST + SGST + IGST + other charges ± round off = total
Real invoices are messy, though. Some print the round-off as +0.40 and others as -0.40 for the same adjustment. Some round silently and never print it. Discounts often appear as positive numbers in a “Less” row.
The check allows for all of this. It accepts a difference of up to one paisa when a round-off is printed, and up to 50 paise when it isn’t.
The amount in words is a second reading
Most Indian invoices also print the total in words, like “Rupees Nine Thousand Three Hundred Four Only”. We convert those words back into a number and compare it with the total.
The converter handles lakhs and crores, paise, and common misspellings like “fourty”. When two independent readings of the same amount agree, that’s strong evidence the total is right.
The tax type has to match the place of supply
GST has a clear rule here. A supply within one state is taxed as CGST plus SGST, split equally (in union territories, UTGST takes the place of SGST). A supply between states is taxed as IGST alone. Supplies to SEZ units are a known exception, since they’re treated as inter-state even within the same state.
We know the supplier’s state from the first two digits of their GSTIN, and the place of supply from the document. So the tax lines on the document have to agree with those two facts.
The most important check: is the value really in the document?
For digital PDFs, every value the model returns must appear in the PDF’s own text.
This is the check that catches hallucinations. An invented invoice number passes every format rule. It doesn’t pass “show me where this is printed”.
Dates need special care. The model returns 2026-10-16, but the document almost never prints it that way. So we search for every common Indian format: 16/10/2026, 16-10-26, 16-Oct-2026, 16th October 2026 and so on.
The government QR is the strongest witness
When an e-invoice QR code is present, the GSTINs, document number, date, and total must match it exactly. This works even on scanned documents, where there’s no text layer to check against.
Scanned documents need a second witness for the total
On a scanned document, a misread digit in the total is the most expensive mistake possible. So a scanned document is only accepted if its total is confirmed independently, either by the amount in words or by the QR code.
What it costs
A typical digital invoice uses about 6,000 input tokens and 200 output tokens. Almost all of that is the page images and text going in, not the answer coming back.
On the model we use, that works out to roughly $0.014 per document, a little over ₹1.3 at current exchange rates. The client pays it with their own key. The exact figure will vary with each provider’s pricing, but the shape stays the same: input is the bulk of the cost.
Every request is logged, whether it succeeds or not, so cost and quality can be tracked per client and per prompt version. Each log entry records:
- the tokens used and how long the request took;
- the provider, model and prompt version;
- the raw AI answer, and exactly what we sent back.
What we learned
- The interesting engineering happens around the model, not inside it. The AI call itself is a single line. Safe downloading, page selection, text reconstruction, QR decoding, and the checks are where the reliability comes from.
- Make “I don’t know” a valid answer. Letting an empty string mean “not printed” removed a whole class of invented values.
- Describe roles, not labels. Documents label the same thing in endless ways. Roles stay the same.
- Free text plus verification beats a fixed list. Copying exactly what’s printed, then checking it’s really there, worked better than forcing the model to pick from our categories.
- Go back to the original data. Decode the QR from the embedded image.
- Every check should explain itself. “NEEDS REVIEW: buyer GSTIN check character is wrong” is something a person can act on in seconds.
- Spend money last. Run every free check before the paid one.