llms.txt
# space-ocr
> space-ocr is a field extraction API with built-in verification (and a web app on top): extract the fields you name, and know what to review. It reads a document image three ways — as structured named fields, as layout-preserving Markdown, or as plain text. Whichever shape you ask for, every value comes back with the on-page coordinates it was read from plus a verification flag, and anything the cross-check could not confirm lands on an explicit review list (paths and reasons — a work list, not a bare confidence score), so silent mismatches get surfaced and results can be traced back to the pixels they came from. Field results are stored as queryable sheets (no database needed) and export to UTF-8 BOM CSV that opens cleanly in Excel, including Japanese/Korean/Chinese text; Markdown and plain-text results export as .md / .txt.
Key facts:
- API base URL: https://api.space-ocr.com — three document endpoints, one HTTP call each: `POST /ocr/fields` (structured fields), `POST /ocr/markdown` (layout-preserving Markdown with per-element boxes), `POST /ocr/text` (reading-order plain text with per-block boxes).
- Verification is uniform across the three endpoints, down to the field names — every response is one four-part structure, `data = { values, cells, review, image }`. `values` is pure user data (exactly the shape you asked for — no reserved keys, storable as-is). `cells` is a flat map keyed by path (`items[0].price`, `elements[2].cells[1]`, `blocks[7]`); each entry carries `box` (axis-aligned rectangle) and `quad` (four tilt-following corners) — both always present, normalized to 0–1000, with `image` converting them back to pixels — plus `verified`, the cell's VERDICT and a mirror of `review` — `false` whenever anything is flagged (including a rule you declared), `true` when nothing is flagged and a check actually ran, `null` when nothing is flagged but there was nothing to check (geometry-only entries like row unions); one field is enough to gate on, `review` (`null` when passed, `{ reasons }` when a human should look — an array ordered by rank, index 0 being the primary verdict, always an array even at length one; the vocabulary: `type_mismatch`, `out_of_range`, `pattern_mismatch`, `near_mismatch`, `near_ambiguous`, `near_conflict`, `nobox`, `text_mismatch`, `crop_mismatch`, `low_ratio`, `weak_source`, `low_ocr_confidence`, `ambiguous_occurrence`, `overwide_box`, `missing`), and `evidence` with the raw signals behind the verdict: `text_match` (the character cross-check itself, present only when it ran), `source` (how the coordinate was produced), `match_ratio`, `printed_text` (what the OCR pass itself read at those coordinates — present on field extraction; `values` is the model reading and the character cross-check folds full-width forms, brackets, hyphens, case and whitespace before comparing, so a value the model re-spelled still passes, and this is the side to use when you need an exact string match such as reconciling against a master table; it does not supersede `values` — the OCR pass has its own misreads, which is why the two are cross-checked — and it joins glyphs only, so spacing between words is not reconstructed — compare ignoring whitespace, and do not read a missing space as proof the page prints none), `ocr_confidence` (the lowest of the OCR pass's own per-glyph confidence scores under the value — below 0.5 the value is flagged `low_ocr_confidence` even when the engines agree, the one witness left when both agree on the same misread), and `crop_verified` (a second cropped-box re-read that runs only for values the first check could not confirm — a confirming crop promotes the value, a contradicting one flags `crop_mismatch`, an empty read abstains), and `multiline` (present only when true — the value wraps across lines, so its box is the union of them). `review` is the document-level summary: `unit` ("field" / "element" / "block") names what was counted, with `declared` / `returned` on field extraction (`total` plus a token `coverage` block on document conversion), `boxed`, `verified`, and `flagged` — an explicit work list of `{ path, reasons }` whose length IS the review count, with `by_reason` as the breakdown — it counts every entry in `reasons`, not just the ranked one, so its total is at least the number of flagged entries; `cells` keys share the `flagged` path grammar, so a flagged path is a direct lookup. On `POST /ocr/fields` a field declared `required: true` that comes back empty — or is absent from the response entirely — is flagged with reason `missing`: the one error class the character cross-check cannot see, because a value that was never returned has nothing to disagree with; empty values never get a cell, so `review.flagged` is the only place they appear. `required` is never shown to the model, so extraction is unchanged; it is a review declaration, worth setting only on values the document always prints. A top-level non-container field (`string` / `number` / `integer` / `date`) may also declare `label` — the label printed next to the value (one string, or an array of candidates): when the same value appears more than once on the page, the returned coordinates anchor to the occurrence beside that label. A `label` on an array or object, or on any of its children, is ignored — a label printed once on the page cannot say which repeating row a value belongs to, and line-item children are already anchored within their own row, so a shared column heading like 数量 needs no label. Like `required` it never reaches the model; the extracted text is unchanged, only the coordinate anchor moves. Labels spanning several printed words (`消費税(8%)`) work as written, and when the label is not printed exactly once the normal search is used and `review.notes` reports it as issue `label_unresolved` — the value still comes back either way, so that note is the only way to tell a declaration that fired from one that did nothing. The character cross-check itself — does the value equal the words actually read at those coordinates, two independent engines agreeing — is `evidence.text_match`, present only when the check ran. It is not the gate and never was: its failure has its own review reason (`text_mismatch`), and a cell can be `verified: false` with `text_match: true`, meaning the characters agreed and a rule you declared is what caught it. Passing that check is likewise not a claim that the value is the field you meant: if the model picks up a different piece of printed text, the box follows that text and the comparison still agrees — the coordinates are evidence of where a value came from, not proof that it is the right item. A field may also declare what its value should BE: a scalar `type` (`number` / `integer` / `date`), a `pattern` regex (one, or an array of alternatives), `min` / `max` bounds, or an `enum` of allowed values. None of these reach the model either — declaring a type to a language model is what makes it invent a value of that shape — so `values` is unchanged by declaring one. (`values` is the model reading of the page held to the printed text by the character cross-check, not a byte-for-byte copy of it — use `evidence.printed_text` when you need the page own spelling.) What a declared type buys is a fifth part of the response, `data.normalized`: the same reading parsed into that type and shaped exactly like `values`, so `normalized.items[0].qty` sits beside `values.items[0].qty` ("¥13,220" → 13220, "令和8年8月16日" → "2026-08-16", "3袋" → 3). Parsing is deterministic — no extra model call, so the same page yields the same number on every run — and a value that will not parse comes back `null` there and is flagged `type_mismatch`, which is usually a misread worth looking at rather than a nuisance; the per-cell diagnostic (`type`, `method`, `error`) rides on `cells[path].normalized`. `error` names the kind of refusal: `not_numeric` / `not_an_integer` / `not_a_date` mean the reading may be a misread (`l510` for `1510`), `no_year` a date whose year is not printed, `conventional_token` a placeholder the document really prints (`一式`, `別途`, a dash-only cell), and `relative_date` a payment term that leans on another field (`翌月末払い`) — the last two will not yield a value on a re-read, so they belong in your own business rules rather than a review queue. `reasons` stays `type_mismatch` for all of them, so no count moves. Printed month-end dates (`2026年9月末日`, `令和8年9月末`) parse to the calendar's last day; without a year (`9月末日`) it is refused as `no_year` rather than resolved against the issue date. Breaking a declared rule raises `out_of_range` or `pattern_mismatch`. These caller-declared reasons rank above the engine's inferred ones, and because one cell can trip both layers every verdict carries `reasons` — the full list, ordered by rank, with index 0 the primary verdict (the singular `reason` key that shipped alongside it was removed once it became a pure duplicate of `reasons[0]`). `enum` in particular is the slot for handing the API a value set the business already owns — a vendor master's company names, an item catalogue, a unit list (袋/本/個) — and the only handle on a misread both engines agree on (冊 read as 申), which character cross-checking is structurally blind to. A field may also declare `near` — the vocabulary expected to be printed beside its value, one term or an array of alternatives (`["御中", "様"]` for an addressee company, `["登録番号", "〒", "TEL"]` for an issuer block): after extraction the declared terms are located on the page, and a value anchored away from every occurrence is flagged `near_mismatch`. That is the only handle on the class where the model reads perfectly but picks the value from the WRONG spot — a form prints two company names, and picking the other one still passes character cross-checking with `verified: true`, while `enum` cannot tell two legitimate master values apart; `near` does not make the pick right, it makes a wrong pick visible. When no declared term is printed anywhere on the page the check abstains and `review.notes` reports issue `near_unresolved`. Like every declaration above it never reaches the model. In the other direction a field may declare `review: "off"`, which withholds the reasons the ENGINE infers for it while keeping every piece of evidence — for free-text columns like 品名 or 備考, so the flags on 登録番号 or 合計 stay readable; it cannot silence `missing` or a constraint violation, because those are rules the caller wrote. The coordinates are never produced by the language model — they come from the OCR pass, so a value can always be checked against the pixels it claims to come from.
- How a page becomes data (the pipeline, in order): an OCR pass reads the page first — paragraph, word and character level — and is the only source of coordinates; a multimodal model then sees the original image alongside that OCR text and decides what to read and in what order, without ever producing coordinates; each returned value is matched character by character against the symbols actually detected on the page, and how much matched is reported as `evidence.match_ratio`, with the model's word-token hints used only as a secondary override on top of that; finally the two sides are cross-checked, which is where `verified` and the `review` flags come from — and any value that check could not confirm has its box cropped and read once more (`evidence.crop_verified`) before it is flagged.
- The accuracy figures on the landing page — field accuracy 91.1% vs 70.0%, and run-to-run variance of 16 vs 100 values out of 463 across three runs of the same pages — come from a controlled side-by-side benchmark against Mistral Document AI (`mistral-ocr-latest`, document_annotation): same photos, same field schema, same grading script (8 cases over 7 documents · 463 fields · 3 runs each · August 2026). The compared photos are shown on the landing page itself, and the full method, raw responses and grading code are public: https://space-ocr.com/articles/space-ocr-vs-mistral-ocr-benchmark/ — Earlier internal regression-corpus figures (93.7% value accuracy with every verification stage on, 22 cells fixed / 0 broken, 92.1% of returned boxes spelling their value, 6.4× lift for `text_verified: false` (the flag's v1-era name; the same signal is `evidence.text_match` in today's v2 responses); 333 hand-labelled cells, July 2026, not a public benchmark) remain published with method at https://space-ocr.com/articles/measuring-ocr-verification/
- Engine identity is public: `GET /health` (no auth, not billed) reports the public API's contract version (`version`, currently v2.8), the running engine version, its deploy stamp (`engine.build.sha` / `engine.build.deployed_at`) and two caller-facing changelogs of behaviour-visible changes, newest first — the top-level `changelog` for the API itself (paired with `version`, `v2.x`) and `engine.changelog` for the engine (paired with `engine.version`, `vNN`). They are separate axes and either can move alone. The request/response structure is stable, so entries describe additive keys and content-level shifts. Integrators measuring the engine (error rates, flag volumes, re-run stability) should record both versions alongside their measurements.
- Synchronous OCR waits up to 180 seconds for the engine; past that the call returns 504 `ocr_engine_timeout` and is not charged. When a document does hit that ceiling the cause is almost always density — small type across several pages — rather than pixel count, so the fix is to send one image per page, or to use the async `POST /upload` path, which allows longer processing. Downscaling a dense page usually makes it unreadable rather than faster.
- Request size: the JSON body caps at 28MB (base64 ≈ 1.33× the file, so roughly a 20MB original image); over that returns 413 with `details.limitBytes` / `details.receivedBytes`. Requests over 32MiB are cut off by the infrastructure before reaching the API, and that response is HTML rather than JSON. For bigger files pass a URL or use `POST /upload` (async, 20MB per file) with `GET /jobs/:jobId` polling or webhooks. Large photos may be downscaled to 4000px on the longest side before reading, depending on how the image encodes server-side; the size actually read is reported as `data.image`, and since coordinates are normalized to 0–1000 their meaning is unchanged. Orientation is handled the same way: EXIF orientation is baked into the pixels before reading, so a photo taken sideways is read as an upright page and both values and coordinates come back in that orientation — `data.image` is where width and height swap relative to the file you sent (send 4000×3000, get 3000×4000). `data.image` is therefore the frame every coordinate lives in: draw outlines against it, never against the dimensions of the file you uploaded. No deskew is applied — the page is never rotated to rebuild coordinates, so on a tilted photo the quads follow the tilt, in the input image's own coordinate system.
- Results are also a storage layer, so there is no database to stand up: `POST /create` makes a folder, a sheet (with its own column schema), a document bundle or a memo; `POST /upload` sends up to 20 images into a sheet or bundle and each image becomes one row or one page; `POST /edit` overwrites a single cell or a memo body; `POST /remove` deletes. Items are addressed by slash path (`/invoices/2026-08`) or by the `uniqueKey` returned at creation, and `GET /space?path=&depth=` walks the tree.
- Stored results are queryable via `GET /view` without re-running OCR: on sheets it takes repeatable `where` filters (`total>=10000`, `vendor~ABC`; operators `= != > >= < <= ~` over the sheet's columns plus `name` / `ocrStatus` / `createdAt`; stored values are the model reading of the page, so comparisons normalize full-width characters and strip currency marks, digit separators and accounting negatives before deciding whether to compare numerically), `sort` (`-invoice_date`, repeatable for tie-breaks), `select` projection, `limit` / `offset` paging with a `nextOffset` cursor, and `boxes=0` to drop coordinate payloads — all server-side and not charged. Rows come back with their values plus the same per-value coordinates and verification flags the OCR endpoints return. The query parameters apply to sheets only; folders, document bundles, memos and images return their full contents. Webhooks (`item.created` / `upload.received` / `ocr.completed` / `ocr.failed`) drive automation on top of the same store.
- AI agents reach the same API through an MCP server, so an agent can read a document and then keep what it read: three reading tools (`ocr_extract`, `ocr_markdown`, `ocr_text`) and ten workspace tools (`space_guide`, `space_list`, `space_view`, `space_create`, `space_inbox`, `space_upload`, `space_job`, `space_edit`, `space_balance`, `space_delete`) that call the REST routes above with identical billing — only reading an image and uploading one cost a credit. `space_upload` takes images that are already public https:// URLs, so anything else — a file on the user's disk, a photo attached to the conversation — goes in through `space_inbox`, which opens a short-lived upload link the files are sent to directly; `space_guide` returns the server's own how-to guides (`start`, `upload`, `schemas`, `verification`, `queries`, `workflows`) without a network call or a credit. Hosted over Streamable HTTP at `https://mcp.space-ocr.com/mcp` — there is no install and no local mode. Clients that can set a header send the API key as a bearer token; clients that cannot (claude.ai, Claude Desktop, Claude mobile) add the URL as a custom connector and authorize through OAuth, choosing which API key the connector acts with. The server also publishes MCP resources (`space-ocr://guide/schemas`, `/verification`, `/queries`, `/workflows`) covering schema design, how to read the verification flags, the filter language, and batch processing, plus prompts (`file_documents`, `review_flagged`, `ask_documents`). Two differences from REST: `space_view` omits coordinates unless you pass `boxes: true`, and `space_delete` is a two-step tool — called without a confirm token it deletes nothing and returns what would be removed (counts and a sample) plus a token signed against the caller's key and that exact path, so an agent has to show the user before it can call again to actually delete. The root path is always refused.
- `POST /ocr/text` restores true reading order with a vision model (multi-column and skewed scans do not come back interleaved) and degrades to a raw-OCR-order transcription rather than failing; `useLlm: false` opts out. There is no language setting to configure on the request.
- In the web app you pick the output shape at upload time: a sheet (each photo becomes a row of named fields) or a doc bundle (each photo becomes one `.md` or `.txt` page). Both keep per-value coordinates, so hovering any cell, heading, paragraph or table focuses the exact region of the source photo it came from.
- Pay-per-credit pricing with a free monthly tier; no credits are charged for failed scans. Consumption order is free quota → flat-fee plan → prepaid balance, so the prepaid balance does not move while free quota remains — a remaining-credit display should show both `free.remaining` and `balance`.
- The site is available in English (default), Japanese (`/ja/`), Korean (`/ko/`), Simplified Chinese (`/zh/`), and Traditional Chinese (`/zh-TW/`). URLs below are the English canonicals; swap in the locale prefix for translations.
## Docs
- [API Documentation](https://space-ocr.com/docs): REST API reference — authentication, `POST /ocr/fields`, `POST /ocr/markdown`, `POST /ocr/text`, the MySpace storage and query routes, webhooks, the MCP server for AI agents, and response schemas with bounding boxes.
- [Pricing](https://space-ocr.com/pricing/): plans, pay-as-you-go rates, and the free tier.
## Guides
- [Best OCR Software for Receipts and Invoices (2026