An OCR API that returns bounding boxes you can verify
Most OCR APIs return bounding boxes, but the coordinate systems differ and a box only says where a value came from. A developer's guide to source coordinates in data.cells[path] — a 0–1000 box, an oriented quad, and the verified and review fields that name the values worth a second look.
Bounding boxes are how you verify OCR. A bare string tells you what the model thinks it read; a box tells you where on the page it read it, so you (or your reviewer, or your code) can check the value against the original instead of trusting it blind. If you're integrating OCR into anything that gets audited — invoices, expenses, KYC, records management — "the model returned total: 2,045" is not enough; you need to point at the pixels that 2,045 came from.
The good news: most mainstream OCR APIs do return bounding boxes. The catch is that they differ in three ways that matter once you start building — the coordinate system, whether you also get structured fields (not just raw text), and what the per-value signal beside each box actually is. This guide walks through all three, and shows what an OCR API looks like when the coordinates arrive with an explicit review contract: a verdict per value, and a list of the paths worth a second look.
Most OCR APIs return boxes — here's how they differ
Google Cloud Vision, Tesseract, Amazon Textract, and Azure AI Document Intelligence all return geometry with their text. They diverge on the coordinate system, on whether you get structured fields or only raw text + layout, and on what the per-value number reports. The table below summarizes each vendor's public documentation as of August 2026 — these products move, so re-check the current docs before you size the integration work.
| API | Coordinate system | Structured fields | Per-value signal |
|---|---|---|---|
| Google Cloud Vision | boundingPoly vertices in pixels of the source image (some features return normalizedVertices instead) | Text + geometry (structured key-values are Google Document AI, a separate product) | Recognition confidence per word/symbol (0–1) |
| Tesseract | hOCR / TSV boxes in pixels (a local library, not a hosted API) | None — raw text + layout | Recognition confidence per word (0–100) |
| Amazon Textract | BoundingBox normalized 0–1 of page width/height (+ Polygon, also 0–1) | Forms/tables via AnalyzeDocument; receipts via AnalyzeExpense | Recognition confidence (%) per block |
| Azure Document Intelligence | Bounding polygon in pixels (images) or inches (PDF) | Prebuilt/custom models | Recognition confidence per word |
| space-ocr | box normalized 0–1000 plus an oriented quad, keyed by the field path you declared | Fields you declare (children for line items), or autoFields | verified verdict + the review.flagged work list, with evidence behind each |
Two things to notice. First, coordinate units aren't portable — pixel boxes are tied to the exact image that was read, while normalized boxes survive a resize. Second, the per-value column isn't the same measurement everywhere: a recognition confidence says how sure an engine is about its own reading, which is a different question from whether the returned value was found on the page at all.
How the box is derived matters as much as its format. With space-ocr, the language model returns each field's text — and a hint of which word tokens it used — but never the boxes themselves. The engine character-matches that text against the symbols the vision OCR actually detected on the page, so the box lands on the real pixels those characters were found at. Where that cross-check runs, the cell's evidence carries a match_ratio for how much of the value was located; where there was nothing to compare against, the key is simply absent. The token hints can be noisy (they sometimes swap between repeated rows), so column- and row-consistency checks validate them rather than trusting them blindly. That's the difference between a coordinate the model asserts and one that's checked back against the page.
What space-ocr returns for every value
Business data stays in data.values, in exactly the schema you asked for. Everything about where a value came from and whether it held up sits in a parallel map, data.cells, keyed by the same path — total, or items[0].price for a line item. Each cell carries:
box— an axis-aligned rectangle{ xmin, ymin, xmax, ymax }of integers on a 0–1000 normalized grid (0,0 = top-left, 1000,1000 = bottom-right), independent of the image's pixel size.quad— four ordered points (top-left, top-right, bottom-right, bottom-left) forming an oriented box that follows the document's tilt, so a skewed phone photo still boxes cleanly. It is always returned alongsidebox.verified— the verdict, and a mirror ofreview:falsewhenever anything was flagged,truewhen nothing was flagged and a check actually ran,nullwhen nothing was flagged but there was nothing to check (a row union, say).review—null, or{ reasons }with the reasons ranked and the first one primary. Codes includetext_mismatch,low_ratio,noboxandmissing.evidence— the raw signals behind the verdict:text_match(the character cross-check itself),source(vision_symbol_match,token_id),match_ratio, andprinted_text— the glyphs the OCR pass read at those coordinates.
The same paths show up again in data.review.flagged, which is the work list: one entry per value that needs a look, each with its reasons. data.image gives the width and height of the page as it was read, and that is the frame every coordinate is expressed against. (Declare a scalar type, or a pattern or enum on a string field, and a data.normalized layer appears as well — the string-only example below produces none.)
{
"data": {
"values": { "total": "2,045" },
"cells": {
"total": {
"box": { "xmin": 381, "ymin": 803, "xmax": 500, "ymax": 825 },
"quad": [
{ "x": 380, "y": 804 }, { "x": 500, "y": 801 },
{ "x": 500, "y": 823 }, { "x": 381, "y": 826 }
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "vision_symbol_match",
"match_ratio": 1.0,
"printed_text": "2,045"
}
}
},
"image": { "width": 1654, "height": 2339 }
}
}Pixels or normalized? Convert once, and resizes stop breaking
A recurring OCR-integration bug is that pixel coordinates are tied to the exact image you uploaded — resize or recompress it for storage, or miss an EXIF-rotation flag, and the overlaid boxes drift, crop, or land on the wrong text. Normalized coordinates avoid that whole class of bug: a 0–1000 box maps onto any rendering of the same page.
One thing to get right first: the frame is data.image, not the file you sent. It is the page as it was read — EXIF orientation already baked into the pixels, and a large photo downscaled before reading — so its width and height can come back swapped relative to your upload (send 4000×3000 and you may get 3000×4000). Convert against data.image and the arithmetic holds.
To draw a box on a displayed image, convert once:
- SVG overlay — give the SVG
viewBox="0 0 1000 1000"and draw theboxorquadas-is. - Absolute-positioned div —
leftPct = xmin / 1000 * 100,topPct = ymin / 1000 * 100,widthPct = (xmax - xmin) / 1000 * 100,heightPct = (ymax - ymin) / 1000 * 100. - Back to pixels —
pixel_x = box.xmin / 1000 * data.image.width,pixel_y = box.ymin / 1000 * data.image.height.
Because EXIF orientation is already applied to that page, a rotated phone photo (orientation 6/8) doesn't need a correction pass on your side. The page is never deskewed, though: a tilted photo stays tilted, which is why quad follows the tilt while box stays axis-aligned around it.
curl -s https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer $SPACE_OCR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/receipt.jpg",
"imageType": "url",
"fields": [
{ "name": "vendor", "type": "string" },
{ "name": "total", "type": "string" }
]
}'A confidence score, a match ratio, and a review verdict
This is the distinction worth internalizing. Most OCR APIs document a recognition confidence — a number that reflects how sure the engine is about its own reading, based on things like font clarity and image quality. It's useful, but it's the model grading its own homework. A match ratio measures something external: of the characters in the value the model returned, how many were actually found among the symbols the page-level OCR detected. A value can come back with a healthy recognition confidence and still not line up with anything on the page.
The API doesn't hand you that ratio and leave you to pick a cutoff. match_ratio sits in evidence as the arithmetic behind the verdict; the engine applies the threshold itself — a value counts as a confident character match at 0.85 and above — and when coverage falls short the cell comes back with low_ratio among its review reasons. So the gate in your code is review != null, or better, iterating data.review.flagged directly, which also reaches the classes a ratio cannot see: a required field that never came back at all (missing), a value with no coordinate (nobox), a declared pattern or range the value broke.
One combination surprises people and shouldn't: verified: false together with evidence.text_match: true. That is a value whose characters matched the page fine, caught instead by a rule you declared. Both are worth reviewing, for different reasons — and neither settles the opposite case, since two engines can agree on the same misread.
Verify, then query — without re-running OCR
Coordinates are most useful when the data sticks around. Push images into a sheet with POST /upload, then query it server-side with GET /view — where, sort, select, limit, offset — to pull, say, every row where total >= 40000, with no re-OCR and no extra charge. Filters target the sheet's own columns (plus name, ocrStatus and createdAt), and each row comes back with its cells map intact, so box and quad are still there; pass boxes=0 to drop them for a lighter payload. For the verification workflow in depth, see validating OCR with bounding boxes and the OCR audit trail.
How to get verifiable bounding boxes from the API
- Request fieldsPOST the image to /ocr/fields with imageType 'url' or 'base64', and either your own fields array or autoFields set to true. The engine reads raster images.
- Read the coordinatesLook up data.cells by field path. Each cell carries a box { xmin, ymin, xmax, ymax } on a 0–1000 grid, a four-point quad, a verified verdict, review, and evidence.
- Overlay or convertDraw boxes with an SVG viewBox '0 0 1000 1000', or convert to pixels with pixel_x = box.xmin / 1000 * data.image.width. data.image is the page as it was read, with EXIF rotation already applied.
- Work the review queueIterate data.review.flagged instead of thresholding a score yourself: each entry pairs a path with its reasons, and cells[path].evidence holds the supporting detail, match_ratio included.
- Store and queryPush images into a sheet with /upload and query it with GET /view (where, sort, select) — each row keeps its cells map with box and quad, with no re-OCR and no extra charge.