Introduction
RESTful, JSON, and CORS-enabled. For batch and async flows, see Jobs / Webhooks.
5-minute quickstart
① Issue a key in Developer → API Keys (no card required).
② Run the curl on the right as-is — the sample image is really hosted, so swapping in your key is all it takes.
③ Check the values under data.values, the box / quad / verified entries in data.cells, and the review list at data.review.flagged. Declare types or constraints on a field (number / date / pattern / enum / near) and the parsed values arrive under data.normalized, violations under review — see fields on POST /ocr/fields for everything you can declare.
④ Want to look before you write code? The MySpace console is your playground. Drop a file into a sheet and you get the same result the API returns — click any cell to see where it came from in the source image. Documents uploaded through the API land in that same sheet, so automated processing and human review share one place.
curl -X POST https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://space-ocr.com/samples/two-receipts.jpg",
"imageType": "url",
"fields": [{ "name": "store_name" }, { "name": "total" }]
}'Authentication
Keys are prefixed with spocr_. Revoke immediately if leaked.
curl https://api.space-ocr.com/amount \
-H "Authorization: Bearer YOUR_API_KEY"Base URL
# Production
https://api.space-ocr.com
# OpenAPI spec
https://api.space-ocr.com/openapi.jsonRate limits
Every response carries X-Request-Id (req_xxx) and X-RateLimit-Remaining (calls left in the current minute). Include the X-Request-Id when contacting support.
/ocr/fields, /create and /upload support an Idempotency-Key header. Repeated requests return the cached response for 24h with X-Idempotent-Replay: true.
Image size & latency
The JSON body caps at 28MB. Base64 inflates a file by ≈1.33×, so one request carries roughly a 20MB original image. Over the cap returns 413, with details.limitBytes for the allowed size and details.receivedBytes for what arrived.
Requests over 32MiB are cut off by Google Cloud before they reach our code. That response is text/html (Google Frontend's 413 page), not JSON — so a client that always parses the body as JSON will throw. Staying under the 28MB cap above keeps you out of this path.
Large photos may be downscaled to 4000px on the longest side before reading, depending on how the image encodes on our side (aspect ratio preserved). The size actually read is reported in data.image width / height, so it can differ from what you sent. Coordinates are normalized to 0–1000, so downscaling does not change what they mean. If you downscale client-side, 4000px on the longest side is the figure to aim at.
Orientation works 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. This is when data.image width and height swap relative to your file — send 4000×3000, get 3000×4000. Draw outlines against data.image, not against the size of the image you sent.
For images past the cap, pass a URL with imageType: "url", or use /upload (async, 20MB per file) with /jobs polling or a webhook. A synchronous call whose processing exceeds 180s returns ocr_engine_timeout — the cause is usually density (small type packed across several pages) rather than pixel count, so split into one image per page or move to the async path instead of downscaling.
Reproducibility
Value extraction goes through a model. The same image has been measured returning a differently-composed result run to run — how much text comes back as one value, where line items are split. The coordinate cross-check and the normalized parse are deterministic (no extra model call), but the extraction feeding them is not.
Where being able to produce the same number later is part of the requirement — bookkeeping, audit — keep the response JSON as your record and run your checks against the stored values. Re-read only when the document itself changed, or when discarding the earlier review is acceptable.
The Idempotency-Key header is a retry safeguard that replays the same response for 24 hours — it is not a storage mechanism (see the data-handling policy for what is kept).
Errors
{
"error": {
"code": "validation_failed",
"message": "imageType is required",
"requestId": "req_xxx"
},
"details": {
/* optional, endpoint-specific context (e.g. /upload returns processable count) */
}
}
// error.code: validation_failed | bad_request | invalid_image | invalid_api_key
// | key_inactive | unauthorized | forbidden | not_found
// | insufficient_balance | rate_limited | ocr_engine_error
// | ocr_engine_timeout | storage_error | internal_errorHTTP status
Structured OCR
Extract named fields from an image. Give it a schema with fields, or let autoFields propose one.
This is a synchronous call, so the connection stays open until the response returns. Processing is capped at 180 seconds; past that you get a 504 ocr_engine_timeout and are not charged. In practice a single page lands in seconds to tens of seconds, but set your client-side timeout with room to spare. What actually hits the ceiling is dense multi-page paperwork, and the cause is density rather than pixel count — downscaling makes such text unreadable rather than faster, so split into one image per page, or use the async POST /upload path, which allows longer processing.
Body parameters
Base64 string or image URL. The JSON body caps at 28MB, and base64 inflates a file by ≈1.33×, so that is roughly a 20MB original image. Over the cap returns 413 with details.limitBytes / receivedBytes. For anything larger, pass a URL or use /upload (async, 20MB per file).
Images over 4000px on the long edge are downscaled server-side before reading — coordinates come back against the downscaled page (data.image), so there is no need to pre-crush quality to fit the cap.
Extraction schema. Optional when autoFields is set. Values come back as the page reads — nothing is summarized or paraphrased — which is what makes them anchorable to coordinates and verifiable.
They are not a byte-for-byte copy, though: values is the model's reading, and the character cross-check folds full-width forms, brackets and whitespace before comparing, so a re-spelling like (税抜) → (税抜) passes. For exact string matching, use cells[path].evidence.printed_text — what the OCR pass read at those coordinates.
Defaults to string. Declaring number / integer / date does not change values — it comes back the same either way, and the type never reaches the model (telling a model the type is what makes it invent a value of that shape). What a declared type produces is a second layer, normalized: the same reading parsed into that type and shaped exactly like values ("¥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 every run. A value that will not parse comes back null there with reason "type_mismatch", which is usually a misread worth looking at.
Some fields are better left undeclared. A quantity column that legitimately prints 一式, a due date that reads 翌月末払い — the document is correct, but a declared type puts it in the review list on every run (error does name the kind, conventional_token / relative_date, but it is still listed). Declare a type only where the value is always a number or a date; take the rest as string and resolve them with your own business rules.
The label printed next to the value (e.g. "Total"). When the same value appears more than once on the page, the coordinates anchor to the occurrence beside this label. It fires only when the label is printed exactly once on the page; otherwise it falls back to the normal search AND says so in review.notes as issue: "label_unresolved" (the value still comes back, so without that note there is no way to see the declaration is inert). Labels spanning several words — "消費税(8%)", "10%対象 小計" — work as written. Like required, it is never shown to the model — the extracted text is unchanged, only the coordinate anchor moves. Pass an array for multiple candidates (e.g. ["Issue date", "Date of issue"]).
It applies to top-level string / number / integer / date fields only. A label on an array or object itself, or on any of its children, is ignored (a label printed once on the page cannot say which repeating row a value belongs to). For a line-item table whose column headings — quantity, amount — are shared by every row, no label is needed: each child's coordinates are resolved within its own row. To hint at position inside a row, use description instead (e.g. "to the right of unit price").
The vocabulary expected to be printed beside the value (e.g. ["御中", "様"] for an addressee company, ["登録番号", "〒", "TEL"] for an issuer block). After extraction the declared terms are located on the page, and if the value sits in none of their neighbourhoods, reason "near_mismatch" is raised.
This 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. enum cannot tell them apart either when both are legitimate master values. near does not make the pick right — it makes a wrong pick visible.
The check runs over EVERY occurrence of the value (v85). If no occurrence sits beside the vocabulary you get near_mismatch (wrong wherever it is printed); if one does but the coordinates landed on a different copy you get near_ambiguous (which copy is meant is undecided — the value may well be correct). The split exists because on a form printing the same value twice, a correct value passed or failed purely on where the box happened to land. The arithmetic behind the verdict rides in cells[path].evidence.near.
match says WHERE in a printed word a term may sit: boundary (default — the term is the whole word, or its first/last part), suffix (御中, 様, 宛), prefix (〒, TEL, 登録番号), standalone (never glued), anywhere (the v81 behaviour). The default changed in v85: v81 accepted a term anywhere inside a longer word, so 様 inside the job name 中野様邸増築工事 witnessed an ADDRESSEE check on a form printing no 御中 at all. Declare { "match": "anywhere" } to restore v81 exactly. A term printed as its own word is unaffected under every mode.
When none of the declared terms is printed anywhere on the page, the check abstains and review.notes carries issue: "near_unresolved" (a form that simply omits 御中 must not be punished). When that form is precisely where the mix-up happens, not_near is the one that reaches it. Like label it is never shown to the model. The neighbourhood window is engine-defined: ±6 cell-heights horizontally, ±3 vertically.
The mirror of near — vocabulary the value must NOT sit beside (declare ["登録番号", "TEL", "〒"] on an addressee company name). A value in any of their neighbourhoods raises reason "near_conflict", and the term it sat beside plus the distance ride in cells[path].evidence.not_near. Same shapes and same match modes as near (v85).
Why both exist: near can only speak when the identifying mark IS printed. But the forms where parties actually get confused are the office order forms with no addressee line, which print no 御中 at all — there near has nothing to do but abstain. The issuer block, however, always prints something (登録番号 / TEL / 〒). So the reachable statement is the negative one: an addressee sitting inside the issuer block is the issuer.
Absence of the vocabulary is not a violation, so unlike near this never abstains and never files a note — it simply passes. Never shown to the model.
curl -X POST https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/receipt.jpg",
"imageType": "url",
"fields": [
{ "name": "store_name", "type": "string",
"description": "Store name" },
{ "name": "date", "type": "string",
"description": "Date" },
{ "name": "payment_method", "type": "string",
"enum": ["現金", "クレジット", "電子マネー"],
"description": "Payment method" },
{ "name": "invoice_no", "type": "string", "required": true,
"description": "Receipt number" },
{ "name": "items", "type": "array",
"description": "Items",
"children": [
{ "name": "name", "type": "string" },
{ "name": "qty", "type": "string" },
{ "name": "price", "type": "string" }
]
},
{ "name": "total", "type": "number", "required": true,
"label": "Total",
"description": "Total" }
]
}'Response fields
Pure user data, exactly your request schema. No reserved keys mixed in, so it can be stored as-is. A value is the model reading of the page, held to the printed text by the character cross-check — not a byte-for-byte copy, so for exact string matching use cells[path].evidence.printed_text.
Values carry characters like the half-width ¥ (U+00A5) as-is. Encoding them to cp932 / Shift_JIS — CSV export included — can throw on that single character, so keep everything UTF-8.
Four corners that follow a tilted scan. Always present alongside box. Use these for drawing outlines. The frame is not the file you sent but the page as read, which data.image describes — a sideways photo is turned upright before reading (EXIF orientation is baked into the pixels), so width and height can swap: send 4000×3000 and get back 3000×4000. Convert to pixels with data.image.
No deskew is applied — the page is never rotated to rebuild coordinates, so what comes back is the coordinate system of the input image, tilt and all; on a tilted photo the quad follows that tilt.
This cell's verdict. It mirrors review, so the two can never disagree — false whenever review carries reasons (any kind, including rules you declared), true when nothing was flagged and a check actually ran, and null when nothing was flagged but there was nothing to check (geometry-only entries like row unions). One field is enough to gate on.
The character cross-check itself (does the value equal the OCR text under this box, two independent engines agreeing) lives at evidence.text_match. Its failure has always had a review reason of its own, text_mismatch, so nothing is lost from the verdict.
true still does not mean it is the value you asked for. If the model picked up a different part of the page (a nearby subheading, say), the box follows that text, the comparison agrees, and with nothing flagged it comes back true. Coordinates answer where a value came from, not whether it is the right item — that is what label / near / enum are for.
null means passed; anything else means human review is recommended. reasons: 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. reasons is every rule broken, ordered by rank — index 0 is the primary verdict (always an array, even at length one). The first six are rules you declared, so they rank above the ones the engine infers.
near_mismatch and near_ambiguous answer different questions: the first says NO occurrence of this value sits beside the declared vocabulary (wrong wherever it is printed), the second says one does but not the copy the coordinates landed on (which copy is meant is undecided — the value may well be correct). They were split because on a form printing the same value twice, a correct value passed or failed purely on where the box happened to land (v85). When near_ambiguous is raised, ambiguous_occurrence is not reported beside it — that would state the same fact in a second vocabulary.
When building UI, assign a treatment to every code listed here (and any added later), and design for the array — several reasons can stand on one cell at once. Mapping only a subset breaks the screen on the first unmapped code; falling back to a generic "needs review" for unknown codes is the safe default.
Present only on fields that declared a scalar type (number / integer / date, or a string carrying pattern / enum). This is where you find out why the matching leaf in data.normalized came back null. method is always "deterministic" today (no extra model call).
error names the KIND of refusal: not_numeric / not_an_integer / not_a_date mean we may have misread it (the l510 class), no_year is a date whose year is not printed (8/16, 9月末日), conventional_token is a placeholder the document really does print (一式, 各, 別途, a dash-only cell), and relative_date is a payment term that leans on another field (翌月末払い, 締日から60日). The last two will not yield a value on a re-read — they are for your own business rules to resolve, not for the review queue. reasons stays type_mismatch in every case, so no count changes.
Present only when a declaration did not run as written. Every entry carries path / issue / description — branch on issue.
issue: "type_coerced" means you declared a type this API does not support (declared_type / applied_type come with it). Supported types are string / number / integer / date / array / object; a supported scalar is handled silently and its parsed reading comes back under normalized.
issue: "label_unresolved" means a label you declared anchored nothing — it is not printed, printed more than once, or has no confident value beside it. The value itself still comes back from the normal search, so without this note there is no way to see that the declaration is inert.
{
"status": "success",
"data": {
"values": {
"store_name": "Supermarket ABC",
"date": "2025-04-10",
"invoice_no": "",
"items": [
{ "name": "Milk", "qty": "1", "price": "$1.99" }
],
"total": "$4.94"
},
"cells": {
"store_name": { "box": { "xmin": 14, "ymin": 36, "xmax": 210, "ymax": 58 },
"quad": [{"x":14,"y":36},{"x":210,"y":36},{"x":210,"y":58},{"x":14,"y":58}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 0.98, "ocr_confidence": 0.96 } },
"date": { "box": { "xmin": 14, "ymin": 80, "xmax": 180, "ymax": 102 },
"quad": [{"x":14,"y":80},{"x":180,"y":80},{"x":180,"y":102},{"x":14,"y":102}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "match_ratio": 1.0, "ocr_confidence": 0.99 } },
"items[0]": { "box": { "xmin": 263, "ymin": 460, "xmax": 738, "ymax": 523 },
"quad": [{"x":263,"y":460},{"x":738,"y":460},{"x":738,"y":523},{"x":263,"y":523}],
"verified": null, "review": null,
"evidence": { "source": "vision_symbol_match", "match_ratio": 1.0 } },
"items[0].name": { "box": { "xmin": 263, "ymin": 460, "xmax": 503, "ymax": 492 },
"quad": [{"x":263,"y":460},{"x":503,"y":460},{"x":503,"y":492},{"x":263,"y":492}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "match_ratio": 1.0, "ocr_confidence": 0.97 } },
"items[0].qty": { "box": { "xmin": 333, "ymin": 460, "xmax": 338, "ymax": 490 },
"quad": [{"x":333,"y":460},{"x":338,"y":460},{"x":338,"y":490},{"x":333,"y":490}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0, "ocr_confidence": 0.94 } },
"items[0].price": { "box": { "xmin": 693, "ymin": 460, "xmax": 738, "ymax": 488 },
"quad": [{"x":693,"y":460},{"x":738,"y":460},{"x":738,"y":488},{"x":693,"y":488}],
"verified": false,
"review": { "reasons": ["text_mismatch"] },
"evidence": { "text_match": false, "source": "vision_symbol_match", "match_ratio": 0.62, "ocr_confidence": 0.88 } },
"total": { "box": { "xmin": 380, "ymin": 720, "xmax": 530, "ymax": 742 },
"quad": [{"x":380,"y":720},{"x":530,"y":720},{"x":530,"y":742},{"x":380,"y":742}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0, "ocr_confidence": 0.98 },
"normalized": { "value": 4.94, "type": "number", "method": "deterministic" } }
},
"review": {
"unit": "field",
"declared": 7,
"returned": 6,
"boxed": 6,
"verified": 5,
"flagged": [
{ "path": "items[0].price", "reasons": ["text_mismatch"] },
{ "path": "invoice_no", "reasons": ["missing"] }
],
"by_reason": { "text_mismatch": 1, "missing": 1 }
},
// the declared type lands here, leaving values untouched
"normalized": { "total": 4.94 },
"image": { "width": 1654, "height": 2339 }
}
}Markdown conversion
Convert an image to layout-preserving Markdown. Headings, paragraphs, lists and tables come back as elements, each with coordinates.
This is a synchronous call, so the connection stays open until the response returns. Processing is capped at 180 seconds; past that you get a 504 ocr_engine_timeout and are not charged. In practice a single page lands in seconds to tens of seconds, but set your client-side timeout with room to spare. What actually hits the ceiling is dense multi-page paperwork, and the cause is density rather than pixel count — downscaling makes such text unreadable rather than faster, so split into one image per page, or use the async POST /upload path, which allows longer processing.
Body parameters
Base64 string or image URL. The JSON body caps at 28MB, and base64 inflates a file by ≈1.33×, so that is roughly a 20MB original image. Over the cap returns 413 with details.limitBytes / receivedBytes. For anything larger, pass a URL or use /upload (async, 20MB per file).
Images over 4000px on the long edge are downscaled server-side before reading — coordinates come back against the downscaled page (data.image), so there is no need to pre-crush quality to fit the cap.
curl -X POST https://api.space-ocr.com/ocr/markdown \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/report.jpg",
"imageType": "url"
}'Response fields
Four corners that follow a tilted scan. Always present alongside box. Use these for drawing outlines. The frame is not the file you sent but the page as read, which data.image describes — a sideways photo is turned upright before reading (EXIF orientation is baked into the pixels), so width and height can swap: send 4000×3000 and get back 3000×4000. Convert to pixels with data.image.
No deskew is applied — the page is never rotated to rebuild coordinates, so what comes back is the coordinate system of the input image, tilt and all; on a tilted photo the quad follows that tilt.
This cell's verdict. It mirrors review, so the two can never disagree — false whenever review carries reasons (any kind, including rules you declared), true when nothing was flagged and a check actually ran, and null when nothing was flagged but there was nothing to check (the table element itself — each table cell is verified on its own). One field is enough to gate on.
The character cross-check itself (does the value equal the OCR text under this box, two independent engines agreeing) lives at evidence.text_match. Its failure has always had a review reason of its own, text_mismatch, so nothing is lost from the verdict.
true still does not mean it is the value you asked for. If the model picked up a different part of the page (a nearby subheading, say), the box follows that text, the comparison agrees, and with nothing flagged it comes back true. Coordinates answer where a value came from, not whether it is the right item — that is what label / near / enum are for.
null means passed; anything else means human review is recommended. reasons: 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. reasons is every rule broken, ordered by rank — index 0 is the primary verdict (always an array, even at length one). The first six are rules you declared, so they rank above the ones the engine infers.
near_mismatch and near_ambiguous answer different questions: the first says NO occurrence of this value sits beside the declared vocabulary (wrong wherever it is printed), the second says one does but not the copy the coordinates landed on (which copy is meant is undecided — the value may well be correct). They were split because on a form printing the same value twice, a correct value passed or failed purely on where the box happened to land (v85). When near_ambiguous is raised, ambiguous_occurrence is not reported beside it — that would state the same fact in a second vocabulary.
When building UI, assign a treatment to every code listed here (and any added later), and design for the array — several reasons can stand on one cell at once. Mapping only a subset breaks the screen on the first unmapped code; falling back to a generic "needs review" for unknown codes is the safe default.
{
"status": "success",
"data": {
"values": {
"markdown": "# Quarterly report\n\nRevenue grew year over year.\n\n| Item | Amount |\n| --- | --- |\n| Revenue | 12,000 |",
"elements": [
{ "type": "heading", "level": 1, "text": "Quarterly report" },
{ "type": "paragraph", "text": "Revenue grew year over year." },
{ "type": "table", "rows": 2, "cols": 2, "cells": [
{ "row": 0, "col": 0, "header": true, "text": "Item" },
{ "row": 0, "col": 1, "header": true, "text": "Amount" },
{ "row": 1, "col": 0, "header": false, "text": "Revenue" },
{ "row": 1, "col": 1, "header": false, "text": "12,000" }
] }
]
},
"cells": {
"elements[0]": { "box": { "xmin": 60, "ymin": 48, "xmax": 520, "ymax": 92 },
"quad": [{"x":60,"y":48},{"x":520,"y":48},{"x":520,"y":92},{"x":60,"y":92}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "ocr_confidence": 0.98 } },
"elements[1]": { "box": { "xmin": 60, "ymin": 120, "xmax": 900, "ymax": 160 },
"quad": [{"x":60,"y":120},{"x":900,"y":120},{"x":900,"y":160},{"x":60,"y":160}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id" } },
"elements[2]": { "box": { "xmin": 60, "ymin": 200, "xmax": 640, "ymax": 320 },
"quad": [{"x":60,"y":200},{"x":640,"y":200},{"x":640,"y":320},{"x":60,"y":320}],
"verified": null, "review": null, "evidence": {} },
"elements[2].cells[0]": { "box": { "xmin": 60, "ymin": 200, "xmax": 350, "ymax": 260 },
"quad": [{"x":60,"y":200},{"x":350,"y":200},{"x":350,"y":260},{"x":60,"y":260}],
"verified": true, "review": null, "evidence": { "text_match": true, "source": "token_id" } },
"elements[2].cells[1]": { "box": { "xmin": 350, "ymin": 200, "xmax": 640, "ymax": 260 },
"quad": [{"x":350,"y":200},{"x":640,"y":200},{"x":640,"y":260},{"x":350,"y":260}],
"verified": false,
"review": { "reasons": ["text_mismatch"] },
"evidence": { "text_match": false, "source": "token_id", "ocr_confidence": 0.71 } },
"elements[2].cells[2]": { "box": { "xmin": 60, "ymin": 260, "xmax": 350, "ymax": 320 },
"quad": [{"x":60,"y":260},{"x":350,"y":260},{"x":350,"y":320},{"x":60,"y":320}],
"verified": true, "review": null, "evidence": { "text_match": true, "source": "token_id" } },
"elements[2].cells[3]": { "box": { "xmin": 350, "ymin": 260, "xmax": 640, "ymax": 320 },
"quad": [{"x":350,"y":260},{"x":640,"y":260},{"x":640,"y":320},{"x":350,"y":320}],
"verified": true, "review": null, "evidence": { "text_match": true, "source": "token_id" } }
},
"review": {
"unit": "element",
"total": 6,
"boxed": 6,
"verified": 5,
"flagged": [{ "path": "elements[2].cells[1]", "reasons": ["text_mismatch"] }],
"by_reason": { "text_mismatch": 1 },
"coverage": { "recovered_blocks": 0, "vision_tokens": 40, "tokens_claimed": 40, "token_coverage": 1.0 }
},
"image": { "width": 1654, "height": 2339 }
}
}Plain text OCR
Return just the document's text — no field schema, no Markdown syntax. The model looks at the image and puts the blocks in true reading order, so multi-column layouts and skewed scans don't come back interleaved.
This is a synchronous call, so the connection stays open until the response returns. Processing is capped at 180 seconds; past that you get a 504 ocr_engine_timeout and are not charged. In practice a single page lands in seconds to tens of seconds, but set your client-side timeout with room to spare. What actually hits the ceiling is dense multi-page paperwork, and the cause is density rather than pixel count — downscaling makes such text unreadable rather than faster, so split into one image per page, or use the async POST /upload path, which allows longer processing.
Body parameters
Base64 string or image URL. The JSON body caps at 28MB, and base64 inflates a file by ≈1.33×, so that is roughly a 20MB original image. Over the cap returns 413 with details.limitBytes / receivedBytes. For anything larger, pass a URL or use /upload (async, 20MB per file).
Images over 4000px on the long edge are downscaled server-side before reading — coordinates come back against the downscaled page (data.image), so there is no need to pre-crush quality to fit the cap.
curl -X POST https://api.space-ocr.com/ocr/text -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" -d '{
"image": "https://example.com/note.jpg",
"imageType": "url",
"includeBlocks": true
}'Response fields
Four corners that follow a tilted scan. Always present alongside box. Use these for drawing outlines. The frame is not the file you sent but the page as read, which data.image describes — a sideways photo is turned upright before reading (EXIF orientation is baked into the pixels), so width and height can swap: send 4000×3000 and get back 3000×4000. Convert to pixels with data.image.
No deskew is applied — the page is never rotated to rebuild coordinates, so what comes back is the coordinate system of the input image, tilt and all; on a tilted photo the quad follows that tilt.
This cell's verdict. It mirrors review, so the two can never disagree — false whenever review carries reasons (any kind, including rules you declared), true when nothing was flagged and a check actually ran, and null when nothing was flagged but there was nothing to check (geometry-only entries). One field is enough to gate on.
The character cross-check itself (does the value equal the OCR text under this box, two independent engines agreeing) lives at evidence.text_match. Its failure has always had a review reason of its own, text_mismatch, so nothing is lost from the verdict.
true still does not mean it is the value you asked for. If the model picked up a different part of the page (a nearby subheading, say), the box follows that text, the comparison agrees, and with nothing flagged it comes back true. Coordinates answer where a value came from, not whether it is the right item — that is what label / near / enum are for.
null means passed; anything else means human review is recommended. reasons: 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. reasons is every rule broken, ordered by rank — index 0 is the primary verdict (always an array, even at length one). The first six are rules you declared, so they rank above the ones the engine infers.
near_mismatch and near_ambiguous answer different questions: the first says NO occurrence of this value sits beside the declared vocabulary (wrong wherever it is printed), the second says one does but not the copy the coordinates landed on (which copy is meant is undecided — the value may well be correct). They were split because on a form printing the same value twice, a correct value passed or failed purely on where the box happened to land (v85). When near_ambiguous is raised, ambiguous_occurrence is not reported beside it — that would state the same fact in a second vocabulary.
When building UI, assign a treatment to every code listed here (and any added later), and design for the array — several reasons can stand on one cell at once. Mapping only a subset breaks the screen on the first unmapped code; falling back to a generic "needs review" for unknown codes is the safe default.
{
"status": "success",
"data": {
"values": {
"text": "Sakura Trading Co.\nInvoice\nTotal 1,451",
"blocks": [
{ "text": "Sakura Trading Co." }
]
},
"cells": {
"blocks[0]": { "box": { "xmin": 60, "ymin": 48, "xmax": 470, "ymax": 92 },
"quad": [{"x":60,"y":48},{"x":470,"y":48},{"x":470,"y":92},{"x":60,"y":92}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "ocr_confidence": 0.98 } }
},
"review": {
"unit": "block",
"total": 12,
"boxed": 12,
"verified": 11,
"flagged": [{ "path": "blocks[7]", "reasons": ["text_mismatch"] }],
"by_reason": { "text_mismatch": 1 },
"coverage": { "recovered_blocks": 0, "vision_tokens": 96, "tokens_claimed": 96, "token_coverage": 1.0 }
},
"image": { "width": 1654, "height": 2339 },
"source": "llm"
}
}List tree
Query parameters
curl https://api.space-ocr.com/space?path=/&depth=1 \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{
"path": "/",
"depth": 1,
"items": [
{ "path": "/invoices", "name": "invoices", "type": "folder", "createdAt": 1716700000000 },
{ "path": "/memo_2024", "name": "memo", "type": "memo",
"uniqueKey": "...", "createdAt": 1716700000000, "extensions": null }
]
}
// type: folder | sheet | doc | memo | img. Non-folder items also carry uniqueKey / extensions.View item contents
Returns the contents of any item type — folder, sheet, doc bundle, memo, or image. Sheets come back as a rows array, doc bundles as a pages array. The query parameters (where / sort / select / limit / offset / boxes) apply to sheets only — on any other type they are ignored and the full contents are returned.
Sheet rows are returned in ascending upload order (createdAt). That is the same order POST /edit and POST /remove use for row: N, so the Nth row of a response is row: N.
Query parameters
# Multiple where (AND) + sort + projection + pagination
curl "https://api.space-ocr.com/view?path=/invoices/sheet1\
&where=total>=10000\
&where=vendor~ABC\
&sort=-invoice_date\
&select=vendor,total,invoice_date\
&limit=20&offset=0" \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
// type=sheet
{
"type": "sheet",
"path": "/invoices/sheet1",
"name": "sheet1",
"columns": [ /* ... */ ],
"total": 128, // total rows in the sheet
"matched": 12, // rows that passed where
"offset": 0,
"limit": 20,
"nextOffset": 20, // next page / null when exhausted
"rows": [
{
"rowKey": "img_abc",
"name": "invoice_2025_04_10.jpg",
"createdAt": 1744243200000, // upload time / the default row order
"imageUrl": "https://...",
"ocrStatus": "done",
"values": { "vendor": "ABC Corp", "total": "12000", "invoice_date": "2025-04-10" },
"cells": { /* same box / quad / verified / review as POST /ocr/fields — omitted with boxes=0 */ },
"review": { "unit": "field" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
]
}
// type=folder
{
"type": "folder",
"path": "/invoices",
"items": [
{ "path": "/invoices/2024", "name": "2024", "type": "folder" },
{ "path": "/invoices/Kvho45OXMKw…", "name": "sheet1", "type": "sheet", "uniqueKey": "Kvho45OXMKw…" }
]
}
// type=doc — every page is returned as-is (where / sort / limit / offset / select / boxes are ignored).
// type=doc (mode=markdown)
{
"type": "doc",
"path": "/reports/quarterly",
"name": "quarterly",
"mode": "markdown",
"total": 2,
"pages": [
{
"pageKey": "img_abc",
"name": "page1.jpg",
"imageUrl": "https://...",
"ocrStatus": "done",
"values": { "markdown": "# ...", "elements": [ /* ... */ ] },
"cells": { /* same box / quad / verified / review as POST /ocr/markdown */ },
"review": { "unit": "element" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
]
}
// type=doc (mode=text)
{
"type": "doc",
"path": "/notes/scan",
"name": "scan",
"mode": "text",
"total": 1,
"pages": [
{
"pageKey": "img_def",
"name": "note.jpg",
"imageUrl": "https://...",
"ocrStatus": "done",
"values": { "text": "...", "blocks": [ /* ... */ ] },
"cells": { /* same box / quad / verified / review as POST /ocr/text */ },
"review": { "unit": "block" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
]
}
// type=memo
{ "type": "memo", "path": "...", "name": "todo", "text": "..." }
// type=img
{ "type": "img", "path": "...", "name": "...", "imageUrl": "...", "ocrStatus": "done" }Create folder / sheet / doc / memo
Body parameters
# sheet
curl -X POST https://api.space-ocr.com/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"path": "/invoices",
"type": "sheet",
"name": "sheet1",
"columns": [
{ "id": "amount", "name": "amount", "type": "string", "required": true },
{ "id": "date", "name": "date", "type": "string" }
],
"prompt": "Extract amount and date from invoice"
}'Response fields
// HTTP 201 Created
// sheet/memo は uniqueKey が path に組み込まれて返却される
{ "path": "/invoices/Kvho45OXMKw…", "type": "sheet", "uniqueKey": "Kvho45OXMKw…" }
// Creation fires the item.created webhook. With an Idempotency-Key header,
// retries within 24h replay the same response.
// A required: true column (amount above) shows up in that row's
// review.flagged with reason "missing" whenever its value comes back empty.Upload images
Form fields (multipart)
curl -X POST https://api.space-ocr.com/upload \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "path=/invoices/sheet1" \
-F "files=@invoice1.jpg" \
-F "files=@invoice2.jpg"Response fields
// async (default)
{
"path": "/invoices/sheet1",
"jobs": [
{ "uniqueKey": "...", "originalName": "invoice1.jpg", "jobId": "job_...", "status": "pending" },
{ "uniqueKey": "...", "originalName": "invoice2.jpg", "jobId": "job_...", "status": "pending" }
]
}
// uploading into a doc bundle — same jobs shape; the bundle's mode decides the conversion
{
"path": "/reports/quarterly",
"jobs": [
{ "uniqueKey": "...", "originalName": "page1.jpg", "jobId": "job_...", "status": "pending" }
]
}
// wait=true — returns results, not jobs
{
"path": "/invoices/sheet1",
"results": [
{ "uniqueKey": "...", "originalName": "invoice1.jpg", "jobId": "job_...",
"status": "done", "mode": "sheet",
"result": { /* { values, cells, review, image } — same v2 structure as GET /jobs */ } },
{ "uniqueKey": "...", "originalName": "invoice2.jpg", "jobId": "job_...",
"status": "pending" } // poll /jobs for anything not finished within 30s
]
}
// 402 — insufficient balance
{
"error": { "code": "insufficient_balance", "message": "...", "requestId": "req_..." },
"details": {
"requested": 5,
"processable": 3,
"breakdown": {
"freeRemaining": 0,
"flatfeeRemaining": 3,
"balance": 0,
"perCallCost": 1,
"currency": "scans"
}
}
}Edit sheet cell or memo
Body parameters
# sheet
curl -X POST https://api.space-ocr.com/edit \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"/invoices/sheet1","row":"img_abc","column":"amount","value":"12000"}'
# memo
curl -X POST https://api.space-ocr.com/edit \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"/todo","text":"new body"}'Response fields
{ "ok": true, "patched": { "row": "img_abc", "column": "amount", "value": "12000" } }Delete (cascade)
Body parameters
curl -X POST https://api.space-ocr.com/remove \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"/invoices/2024"}'Response fields
{ "ok": true }Poll OCR job status
Path parameters
curl https://api.space-ocr.com/jobs/job_xxx \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{
"jobId": "job_xxx",
"status": "done",
"uniqueKey": "img_abc",
"path": "/invoices/sheet1/img_abc",
"sheetRef": "Kvho45OXMKw…",
"docRef": null,
"mode": "sheet",
"result": {
"values": { "amount": "12000", "date": "2025-04-10" },
"cells": { /* same box / quad / verified / review as POST /ocr/fields */ },
"review": { "unit": "field" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
}Account balance and quota
curl https://api.space-ocr.com/amount \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
Prepaid balance — counted in scans, not currency.
Consumption order is free quota → flat-fee plan → balance, so this number does not move while free quota remains. A remaining-credit display should show both free.remaining and balance — balance alone reads as a number that never goes down while the free quota is being spent.
{
"free": { // monthly free quota
"used": 12,
"limit": 100,
"remaining": 88,
"cycleStart": 1716700000000,
"cycleEnd": 1719378400000
},
"flatfee": { // flat-fee plan (enabled:false when not subscribed)
"enabled": true,
"used": 340,
"limit": 3000,
"remaining": 2660,
"cycleStart": 1716700000000,
"cycleEnd": 1719378400000,
"nextBillingAt": 1719378400000,
"interval": "monthly",
"renewal": true,
"plan": "pro"
},
"balance": 1240, // prepaid balance, in scans
"currency": "scans", // balance is counted in scans, not currency
"perCallCost": 1 // 1 scan per call
}
// Processable pages = free.remaining + (flatfee.enabled ? flatfee.remaining : 0) + balance,
// consumed in that order (free quota → flat fee → prepaid balance).Service health
curl https://api.space-ocr.com/healthResponse fields
The changelog of this API, newest first — each entry carries version, date and changes[].
Worth separating from engine.changelog once. The top-level changelog pairs with version (v2.x — the contract of endpoints, parameters and response keys); engine.changelog pairs with engine.version (vNN — what the reading itself does). They are different axes, so one can move without the other.
{
"status": "ok",
"version": "v2.8",
"time": 1787298502910,
"changelog": [
{ "version": "v2.8", "date": "2026-08-21",
"changes": ["Field extraction responses carry a new evidence key, `printed_text` …", "…"] }
],
"engine": {
"version": "v83",
"build": { "sha": "52b9a92", "deployed_at": "2026-08-21T06:59:16Z" },
"changelog": [
{ "version": "v83", "date": "2026-08-21",
"changes": ["New evidence key `cells[path].evidence.printed_text` …", "…"] }
]
}
}Overview
Events
Payload example — ocr.completed
{
"event": "ocr.completed",
"deliveryId": "dlv_xxx",
"occurredAt": 1716700000000,
"apiVersion": "v2.7",
"data": {
"uid": "...",
"path": "/invoices/sheet1/img_abc",
"parentPath": "/invoices/sheet1",
"uniqueKey": "img_abc",
"sheetRef": "sht_xxx",
"docRef": null,
"mode": "sheet",
"result": {
"values": { "amount": "12000", "date": "2025-04-10" },
"cells": { /* box / quad / verified / review */ },
"review": { "unit": "field" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
}
}Delivery headers
X-Spaceocr-Signature: t=<unix_ms>,v1=<hex>
X-Spaceocr-Timestamp: <unix_ms>
X-Spaceocr-Event: ocr.completed
X-Spaceocr-Delivery: dlv_<id>
Content-Type: application/jsonSignature verification
import crypto from "crypto";
export function verify(secret, headers, rawBody) {
const sig = headers["x-spaceocr-signature"] || "";
const m = sig.match(/^t=(\d+),v1=([a-f0-9]+)$/);
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex"),
);
}Retry policy
Get webhook configuration
curl https://api.space-ocr.com/webhook \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{
"configured": true,
"url": "https://example.com/hooks/space-ocr",
"active": true,
"secretMasked": "••••a1b2",
"createdAt": 1716700000000,
"updatedAt": 1716700000000
}
// when nothing is configured
{ "configured": false }Create or update webhook
Body parameters
curl -X PUT https://api.space-ocr.com/webhook \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/space-ocr","active":true}'Response fields
{
"configured": true,
"url": "https://example.com/hooks/space-ocr",
"active": true,
"secretMasked": "••••a1b2",
"secret": "kJ8s…", // plaintext once, only when minted or rotated
"createdAt": 1716700000000,
"updatedAt": 1716700000000
}Remove webhook
curl -X DELETE https://api.space-ocr.com/webhook \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{ "ok": true }Send a test event
curl -X POST https://api.space-ocr.com/webhook/test \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{ "ok": true, "deliveryId": "dlv_xxx" }List recent deliveries
Query parameters
curl https://api.space-ocr.com/webhooks/deliveries \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{
"items": [
{
"deliveryId": "dlv_xxx",
"event": "ocr.completed",
"url": "https://example.com/hooks/space-ocr",
"path": "/invoices/sheet1/img_abc",
"uniqueKey": "img_abc",
"status": "success", // pending | success | dead
"attempts": 1, // number of attempts
"lastAttempt": {
"at": 1716700000000,
"attemptIndex": 0,
"responseStatus": 200,
"error": null,
"durationMs": 143,
"responsePreview": "ok"
},
"occurredAt": 1716700000000,
"nextAttemptAt": null,
"completedAt": 1716700000143
}
]
}Get delivery detail
Path parameters
curl https://api.space-ocr.com/webhooks/deliveries/dlv_xxx \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{
"deliveryId": "dlv_xxx",
"event": "ocr.completed",
"occurredAt": 1716700000000,
"payload": { /* full event body */ },
"attempts": [
{ "at": 1716700000000, "responseStatus": 200, "ok": true }
]
}Manually redeliver
Path parameters
curl -X POST https://api.space-ocr.com/webhooks/deliveries/dlv_xxx/redeliver \
-H "Authorization: Bearer YOUR_API_KEY"Response fields
{ "ok": true, "deliveryId": "dlv_xxx" }
// The same deliveryId is reused (no new id is minted). The delivery's status
// returns to pending and the new try is appended to attempts.Overview
Connect
# Claude Code
claude mcp add --transport http space-ocr https://mcp.space-ocr.com/mcp \
--header "Authorization: Bearer YOUR_API_KEY"Cursor / VS Code / Windsurf (mcp.json)
{
"mcpServers": {
"space-ocr": {
"url": "https://mcp.space-ocr.com/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}