Getting plain text out of a scan — in the order a human would read it
Use POST /ocr/text to extract reading-order plain text, optional content-only blocks, path-keyed source coordinates, and an explicit review queue.
"Just give me the text" sounds like the easy OCR request. It is also the one that quietly breaks search indexes. A vision OCR pass can find the right words but return them in detection order: the first line from the left column, then one from the right, then back again. Nothing throws an error, yet the resulting document no longer reads like the page.
POST /ocr/text separates that problem from field extraction and Markdown conversion. With its default useLlm: true, it reorders blocks into human reading order and rejoins wrapped lines. The characters and source geometry are still checked against the Vision observations instead of treating a language model's transcription as unquestioned truth.
The two request switches
useLlm defaults to true. Keep it on for multi-column layouts, sidebars, skewed scans, or any text a person will read. Set it to false for a Vision-only transcription in raw OCR order; that path skips the LLM work but still returns the document-level verification object.
includeBlocks defaults to false. Leave it off when all you need is data.values.text. Turn it on for block-level chunking, source highlights, or a review UI. It adds content-only entries at data.values.blocks, metadata at keys such as data.cells["blocks[0]"], and block paths in data.review.flagged.
Make one request
This example enables blocks so a flagged paragraph can be traced back to the image. Authentication and the full parameter reference are in the API documentation.
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/page.jpg",
"imageType": "url",
"useLlm": true,
"includeBlocks": true
}'{
"status": "success",
"data": {
"values": {
"text": "Sakura Trading Co.\nInvoice\nTotal 1,451",
"blocks": [
{
"text": "Sakura Trading Co."
},
{
"text": "Invoice\nTotal 1,451"
}
]
},
"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"
}
},
"blocks[1]": {
"box": {
"xmin": 58,
"ymin": 190,
"xmax": 510,
"ymax": 274
},
"quad": [
{
"x": 58,
"y": 190
},
{
"x": 510,
"y": 190
},
{
"x": 510,
"y": 274
},
{
"x": 58,
"y": 274
}
],
"verified": false,
"review": {
"reasons": [
"text_mismatch"
]
},
"evidence": {
"text_match": false,
"source": "char_matcher_fallback"
}
}
},
"review": {
"unit": "block",
"total": 2,
"boxed": 2,
"verified": 1,
"flagged": [
{
"path": "blocks[1]",
"reasons": [
"text_mismatch"
]
}
],
"by_reason": {
"text_mismatch": 1
},
"coverage": {
"recovered_blocks": 0,
"vision_tokens": 9,
"tokens_claimed": 9,
"token_coverage": 1
}
},
"image": {
"width": 1654,
"height": 2339
},
"source": "llm"
}
}Read the response by responsibility
data.values.textis the full plain-text document. With blocks enabled,data.values.blockscontains the same content split into{ text }units, with no geometry mixed into the content.data.cells[path]is the source sidecar.boxandquaduse a 0–1000 page grid; usedata.image.widthandheightto project it onto the processed image.verifiedsays whether the block matched the Vision text at that location after normalization. It is a cross-check verdict, not an accuracy percentage.data.review.flaggedis the work queue. Eachpathdirectly indexesdata.cells, andcells[path].review.reasonsexplains why inspection is recommended.data.sourceisllmwhen the reading-order pass produced the transcription andvisionon the Vision path.
An indexer can consume values.text, while a reviewer consumes review and cells without parsing coordinates out of the text payload.
const { data } = await response.json();
indexDocument(data.values.text);
for (const flag of data.review.flagged) {
const cell = data.cells?.[flag.path];
queueForReview({
path: flag.path,
reasons: flag.reasons,
box: cell?.box,
quad: cell?.quad,
image: data.image,
});
}Fallback is visible, not silent. If the LLM reading-order pass fails, the endpoint returns a Vision transcription instead of turning that failure into an OCR error. In that response data.source is "vision" and data.warning carries the reason. Unclaimed Vision tokens can also be appended as recovered blocks with cells[path].evidence.source: "unclaimed_tokens", so omitted text can be surfaced rather than quietly discarded.
Plain text or Markdown?
Choose plain text for full-text search, embeddings, diffing, accessibility feeds, and archives where headings and tables do not need their own types. If structure matters, use POST /ocr/markdown; the image-to-Markdown guide explains its element-oriented response. For more on geometry and review metadata, see OCR source coordinates.
- Choose the reading-order pathSend the image to POST /ocr/text. Keep the default useLlm:true when reading order matters; use false only when raw Vision order is acceptable.
- Request blocks when you need provenanceSet includeBlocks:true for values.blocks, path-keyed cells, and block-level review metadata; otherwise consume values.text alone.
- Process the explicit review queueIterate data.review.flagged, resolve each item with data.cells[flag.path], and draw its box or quad over the frame described by data.image.
- Record which transcription path ranStore data.source with the text, and surface data.warning when source is vision after an automatic fallback.