space ocr

API Docs

https://api.space-ocr.com
#

Introduction

The space ocr API reads a document image as named fields, layout-preserving Markdown, or plain text — every value coming back with the coordinates it was read from and a verification flag. MySpace stores and queries those results. One spocr_* API key unlocks every REST endpoint and event webhook.

RESTful, JSON, and CORS-enabled. For batch and async flows, see Jobs / Webhooks.

#

5-minute quickstart

Issue a key, paste one curl, read the JSON. Your first call takes under five minutes — with 100 free pages every month.

① 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.

Request
1
2
3
4
5
6
7
8
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

Every request must send Authorization: Bearer <API_KEY>. Issue and revoke keys in Developer → API Keys.

Keys are prefixed with spocr_. Revoke immediately if leaked.

Request
1
2
curl https://api.space-ocr.com/amount \
  -H "Authorization: Bearer YOUR_API_KEY"
#

Base URL

There is a single production base URL. Versioning is signalled via keys and event apiVersion.
1
2
3
4
5
# Production
https://api.space-ocr.com

# OpenAPI spec
https://api.space-ocr.com/openapi.json
#

Rate limits

60 req/min/key, 600 req/min/uid. Exceeded requests return HTTP 429 with a Retry-After header (seconds).

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

Response time is driven mainly by document density and how much you ask it to read (the number of declared fields) — not by pixel count. Observed distribution: p50 7.2s / p90 10.5s, measured across all production calls, most of which declare a few fields; a dense call declaring dozens of fields lands above this distribution (not an SLA).

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

For any number you will treat as final, store the response and use that. Reading the same image again is a new reading, not a retrieval of the same answer.

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

4xx / 5xx errors share a common envelope. requestId helps support trace the issue.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "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_error

HTTP status

200
Success
400
Malformed request / validation failed. Also returned when the image URL cannot be fetched or the base64 is corrupt (code: invalid_image, not charged) — retrying returns the same result, so fix the input
401
Invalid or missing API key
402
Insufficient balance. /upload returns details.requested / processable / breakdown
403
Resource outside this key's scope (e.g. a job created by another key)
404
Path not found / called outside api.space-ocr.com
413
Body over 28MB / file over 20MB. details.limitBytes carries the allowed size and details.receivedBytes the size received (limitBytes only, when there is no Content-Length). Requests over 32MiB are cut off by the infrastructure, and that one 413 comes back as HTML rather than JSON
429
Rate limited. Retry-After header gives wait seconds
500
Internal error
502
OCR engine error (auto-refunded). message carries the actual reason. Problems with the image input itself come back as 400 invalid_image instead, so a 502 is worth retrying
504
The synchronous call did not finish within its 180s ceiling (not charged). The cause is almost always density rather than pixel count, so split into one image per page or use the async /upload path, which allows longer processing, rather than downscaling
#
POST/ocr/fieldsBearer$0.05 (tax incl.)

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

imagestringrequired

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.

imageType"base64" | "url"required
Explicit type of image. The legacy name image_type still works (deprecated).
fieldsarray<FieldSpec>optional

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.

namestringrequired
Becomes the key in the response JSON.
type"string" | "array" | "object" | "number" | "integer" | "date"optional

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.

descriptionstringoptional
A hint for where the value sits (e.g. "to the right of Total").
childrenarray<FieldSpec>optional
Child fields when type is array / object — the same FieldSpec shape, recursively. Declare line items as type: "array" with children rather than counting rows into fixed fields; how many rows come back is up to the page. Each child's coordinates are resolved within its own row, so a column heading repeated on every row (quantity, amount) is never confused across rows. Their cells keys and review.flagged[].path are indexed paths like items[0].amount.
requiredbooleanoptional
A field marked true that comes back empty — or is missing from the response entirely — is recorded in review.flagged with reason "missing". That is the one class the character cross-check can never see: a value that was never returned has nothing to disagree with. It is never shown to the model, so extraction is unchanged (telling the model a field is required would contradict the instruction not to infer values that are not printed). Turn it on only for values the document always prints — switching it on everywhere buries the signal.
labelstring | string[]optional

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").

nearstring | string[] | { terms, match }optional

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.

not_nearstring | string[] | { terms, match }optional

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.

patternstring | string[]optional
A regex the normalized value must satisfy. Same partial-match semantics as JSON Schema, so write ^…$ to check the whole value. Pass an array for alternatives — any one matching is enough. String fields only. Matching runs against the width-folded value, so a plain ASCII pattern works on a full-width page (T12… is compared as T12…). Breaking it raises reason "pattern_mismatch". Never shown to the model — telling it the shape is what makes it invent a value of that shape.
min / maxnumberoptional
Inclusive bounds for a number / integer field, checked against the normalized number. Outside the range raises reason "out_of_range".
enumstring[]optional
The slot for handing the API a value set your business already owns — a vendor master's company names, an item catalogue, a unit list (袋 / 本 / 個). A normalized value outside the set raises reason "pattern_mismatch". It is also the only handle on the class where both engines agree on the same misread (reading 冊 as 申) — character cross-checking is structurally silent when both sides make the same mistake. Note it cannot tell two legitimate set members apart (a registered-but-wrong vendor still passes) — that spot is near's job.
review"normal" | "off"optional
Set "off" to withhold the reasons the engine infers for this field (text_mismatch, low_ratio, ambiguous_occurrence, …). Every piece of evidence is kept; only the verdict is withheld. Put it on free-text columns like 品名 or 備考 so the flags on 登録番号 or 合計 stay readable. It cannot silence a rule you declared (required's missing, or a pattern / min / max / enum violation) — a rule you wrote should not be cancellable by another key you wrote.
autoFieldsbooleanoptional
When true, the LLM proposes a schema if fields is not provided. The legacy name auto_fields still works (deprecated).
promptstringoptional
Free-form natural language instruction (optional).
Request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

status"success"
Always "success" on success. Errors come back as HTTP 4xx/5xx with the common error envelope (see Errors) instead of this body.
data.valuesobject

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.

data.cellsmap<path, Cell>
A flat, path-keyed coordinate/verification map. Keys use the same grammar as review.flagged[].path (items[0].price), so a flagged path is a direct O(1) lookup. A row path like items[0] is the row's union box.
box{ xmin, ymin, xmax, ymax }
The axis-aligned rectangle, normalized to 0–1000 (convert to pixels with data.image). Good for axis-based work like sorting or region tests. On a tilted photo it can enclose more than the value itself, and when it does so excessively reason overwide_box is raised.
quad[{ x, y } × 4]

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.

verifiedboolean | null

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.

review{ reasons } | null

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.

evidenceobject
The raw signals behind the verdict — text_match (the character cross-check itself: does the value equal the OCR text under this box. Present only when the check ran; when it did not, verified is null too. A cell can be verified: false with text_match: true — the characters agreed and a rule you declared is what caught it) / source (how the coordinate was produced: vision_symbol_match / token_id …) / match_ratio (character-match ratio) / printed_text (what the OCR pass itself read at those coordinates. values is the model's rendering, and the character cross-check above folds full-width forms, brackets and whitespace before comparing — so a value the model re-spelled still passes. Use this 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. Glyphs only — spacing between words is not reconstructed, so compare ignoring whitespace; a missing space here is not evidence that the page prints none) / near (present only when a field that declared near was flagged near_mismatch or near_ambiguous: occurrences = how many times this value is printed on the page, satisfied = how many of those sit beside a declared term, anchored = whether the copy that got the coordinates is one of them, nearest_term and nearest = the closest declared term and the gap to it. The gap is expressed in multiples of the ALLOWED window, so <= 1 would have passed — the window is anisotropic, 6 wide by 3 tall, and one absolute distance cannot be compared against a single threshold) / not_near (only on near_conflict: matched = the term it sat beside, distance = the same multiple) / ocr_confidence (the lowest of the OCR pass's own per-glyph scores; key absent when unavailable) / crop_verified (crop re-read result, only when it ran) / multiline (the value wraps, so its box is the union of those lines; the key is present only when true).
normalized{ value, type, method, error? }

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.

data.reviewobject
The document-level verification summary. Per-field verdicts live in cells; this is the tally plus the review list.
unit"field"
What is being counted.
declaredinteger
Every declared slot including empty values and unreturned required fields (a fixed denominator).
returnedinteger
Non-empty values.
boxedinteger
Cells that carry a box.
verifiedinteger
Cells with verified: true — nothing flagged and a check actually ran. Flagged cells are not counted here; their count is flagged.length.
flagged[{ path, reasons }]
The review list — the review count IS its length (no separate counter), and path shares the cells key grammar. Fields with a value but no box (nobox) and required fields that never came back (missing) have no cell, so they appear only here. missing is raised only for fields declared required — an undeclared field that never comes back raises no flag. reasons is every rule broken, ordered by rank — index 0 is the primary verdict (always an array, even at length one).
by_reasonobject
Per-reason breakdown (e.g. { "pattern_mismatch": 1, "text_mismatch": 1 }). One cell can break a declared rule AND carry an engine suspicion, so every entry in reasons is counted — the total is therefore at least the number of flagged entries (the review COUNT is still flagged.length).
notesarray

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.

data.normalizedobject
Present only when some field declared a scalar type (number / integer / date, or a string carrying pattern / enum). It is a tree shaped exactly like values, with the leaves parsed into that type — normalized.items[0].qty sits beside values.items[0].qty. It is sparse (declared leaves only), and a leaf that would not parse is null, with the reason in cells[path].normalized.error. Parsing is deterministic, so the same page yields the same value on every run. values itself is left alone — this layer is added beside it, and values is the side carrying the coordinates and the verification.
data.image{ width, height }
The size in pixels of the page as read — the frame every coordinate lives in, and what converts the 0–1000 normalized values back (pixel_x = box.xmin / 1000 × width). It is measured after the page is turned upright (EXIF) and after any downscale, so it can differ from the width / height of the file you sent.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
{
  "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 }
  }
}
#
POST/ocr/markdownBearer$0.05 (tax incl.)

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

imagestringrequired

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.

imageType"base64" | "url"required
Explicit type of image. The legacy name image_type still works (deprecated).
promptstringoptional
Free-form extra instruction for the layout pass (optional).
includeElementsbooleanoptional
Default true — the response carries values.elements (content) plus cells (per-element coordinates and verification). Set false for the assembled Markdown string only.
Request
1
2
3
4
5
6
7
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

status"success"
Always "success" on success. Errors come back as HTTP 4xx/5xx with the common error envelope (see Errors) instead of this body.
data.values.markdownstring
The assembled Markdown string.
data.values.elementsarray<Element>
Content-only elements — coordinates and verification flags live in cells. OCR tokens no element claimed are recovered as a trailing paragraph (evidence.source: unclaimed_tokens), so a dropped block never disappears.
type"heading" | "paragraph" | "list_item" | "blockquote" | "code_block" | "thematic_break" | "table"
Kind of element.
textstring
The element's text (all kinds except table).
levelinteger
Heading level (heading only).
rowsinteger
Row count (table only).
colsinteger
Column count (table only).
cells[{ row, col, header, text }]
The table's cells.
data.cellsmap<path, Cell>
A flat, path-keyed coordinate/verification map — elements[3] is an element, elements[2].cells[1] a table cell. Same grammar as review.flagged[].path, so a flagged path is a direct lookup. Omitted when includeElements: false.
box{ xmin, ymin, xmax, ymax }
The axis-aligned rectangle, normalized to 0–1000 (convert to pixels with data.image). Good for axis-based work like sorting or region tests. On a tilted photo it can enclose more than the value itself, and when it does so excessively reason overwide_box is raised.
quad[{ x, y } × 4]

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.

verifiedboolean | null

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.

review{ reasons } | null

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.

evidenceobject
The raw signals behind the verdict — text_match (the character cross-check itself: does the value equal the OCR text under this box. Present only when the check ran; when it did not, verified is null too. A cell can be verified: false with text_match: true — the characters agreed and a rule you declared is what caught it) / source (how the coordinate was produced: token_id / char_matcher_fallback / unclaimed_tokens) / match_ratio (character-match ratio) / ocr_confidence (the lowest of the OCR pass's own per-glyph scores; key absent when unavailable) / crop_verified (crop re-read result, only when it ran) / multiline (the value wraps, so its box is the union of those lines; the key is present only when true).
data.reviewobject
The document-level verification summary. Per-element verdicts live in cells; this is the tally plus the review list.
unit"element"
What is being counted.
totalinteger
Total counted units (each table cell counts on its own).
boxedinteger
Cells that carry a box.
verifiedinteger
Cells with verified: true — nothing flagged and a check actually ran. Flagged cells are not counted here; their count is flagged.length.
flagged[{ path, reasons }]
The review list. The review count IS this array's length (there is no separate counter). path uses the same grammar as the cells keys, so it is a direct lookup. reasons is every rule broken, ordered by rank — index 0 is the primary verdict (always an array, even at length one).
by_reasonobject
Per-reason breakdown (e.g. { "pattern_mismatch": 1, "text_mismatch": 1 }). One cell can break a declared rule AND carry an engine suspicion, so every entry in reasons is counted — the total is therefore at least the number of flagged entries (the review COUNT is still flagged.length).
coverageobject
recovered_blocks / vision_tokens / tokens_claimed / token_coverage — how much of the page came back.
data.image{ width, height }
The size in pixels of the page as read — the frame every coordinate lives in, and what converts the 0–1000 normalized values back (pixel_x = box.xmin / 1000 × width). It is measured after the page is turned upright (EXIF) and after any downscale, so it can differ from the width / height of the file you sent.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{
  "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 }
  }
}
#
POST/ocr/textBearer$0.05 (tax incl.)

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

imagestringrequired

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.

imageType"base64" | "url"required
Explicit type of image. The legacy name image_type still works (deprecated).
useLlmbooleanoptional
Default true — reorders blocks into reading order and rejoins wrapped lines. Set false for a Vision-only transcription (instant, no LLM cost, but raw OCR order).
includeBlocksbooleanoptional
When true the response also carries values.blocks (content) plus cells (per-block box / quad / verified / review). Default false.
promptstringoptional
Custom transcription prompt prefix (optional).
Request
1
2
3
4
5
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

status"success"
Always "success" on success. Errors come back as HTTP 4xx/5xx with the common error envelope (see Errors) instead of this body.
data.values.textstring
The full text — blocks joined in reading order.
data.values.blocks[{ text }]
Content-only blocks (when includeBlocks: true) — coordinates and verification flags live in cells. OCR tokens no block claimed are appended as recovered blocks (evidence.source: unclaimed_tokens), so a dropped paragraph never silently disappears.
data.cellsmap<path, Cell>
A flat, path-keyed coordinate/verification map (blocks[7]). Same grammar as review.flagged[].path, so a flagged path is a direct lookup. Present when includeBlocks: true.
box{ xmin, ymin, xmax, ymax }
The axis-aligned rectangle, normalized to 0–1000 (convert to pixels with data.image). Good for axis-based work like sorting or region tests. On a tilted photo it can enclose more than the value itself, and when it does so excessively reason overwide_box is raised.
quad[{ x, y } × 4]

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.

verifiedboolean | null

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.

review{ reasons } | null

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.

evidenceobject
The raw signals behind the verdict — text_match (the character cross-check itself: does the value equal the OCR text under this box. Present only when the check ran; when it did not, verified is null too. A cell can be verified: false with text_match: true — the characters agreed and a rule you declared is what caught it) / source (how the coordinate was produced: token_id / char_matcher_fallback / unclaimed_tokens / vision_paragraph) / match_ratio (character-match ratio) / ocr_confidence (the lowest of the OCR pass's own per-glyph scores; key absent when unavailable) / crop_verified (crop re-read result, only when it ran) / multiline (the value wraps, so its box is the union of those lines; the key is present only when true).
data.reviewobject
The document-level verification summary. Present even on the Vision-only path (useLlm: false).
unit"block"
What is being counted.
totalinteger
Total number of blocks.
boxedinteger
Cells that carry a box.
verifiedinteger
Cells with verified: true — nothing flagged and a check actually ran. Flagged cells are not counted here; their count is flagged.length.
flagged[{ path, reasons }]
The review list. The review count IS this array's length (there is no separate counter). path uses the same grammar as the cells keys, so it is a direct lookup. reasons is every rule broken, ordered by rank — index 0 is the primary verdict (always an array, even at length one).
by_reasonobject
Per-reason breakdown (e.g. { "pattern_mismatch": 1, "text_mismatch": 1 }). One cell can break a declared rule AND carry an engine suspicion, so every entry in reasons is counted — the total is therefore at least the number of flagged entries (the review COUNT is still flagged.length).
coverageobject
recovered_blocks / vision_tokens / tokens_claimed / token_coverage — how much of the page came back (LLM path only, i.e. useLlm: true).
data.image{ width, height }
The size in pixels of the page as read — the frame every coordinate lives in, and what converts the 0–1000 normalized values back (pixel_x = box.xmin / 1000 × width). It is measured after the page is turned upright (EXIF) and after any downscale, so it can differ from the width / height of the file you sent.
data.source"llm" | "vision"
"llm" is the reading-order pass; "vision" is the automatic fallback when the LLM pass fails (warning carries the reason).
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
  "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"
  }
}
#
GET/spaceBearer

List tree

List folders / sheets / memos in MySpace. Scope with path and depth.

Query parameters

pathstringoptional
Slash-separated. Folders by name; sheets by name (unique within parent) or by the uniqueKey returned from /create. Default "/".
depthinteger 1..10optional
Recursion depth. Default 1.
Request
1
2
curl https://api.space-ocr.com/space?path=/&depth=1 \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

pathstring
The path you asked for.
depthinteger
The recursion depth applied.
itemsarray<Item>
The items under path.
pathstring
The item's full path — pass it straight to /view, /upload, /remove.
namestring
Display name.
type"folder" | "sheet" | "doc" | "memo" | "img"
Kind of item. doc is a document bundle (.md / .txt).
uniqueKeystring
Stable key independent of the name (non-folder items). Also usable as a path segment.
createdAtinteger (epoch ms)
Creation time.
extensionsobject | null
Extra metadata (null when absent; non-folder items).
Response
1
2
3
4
5
6
7
8
9
10
11
{
  "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.
#
GET/viewBearer

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

pathstringrequired
Target path.
wherestring | string[]optional
[Sheets only] Row filter (e.g. total>=40000 or vendor~ABC). Repeatable (AND). Operators: = != > >= < <= ~ (~ means contains). Matches columns plus name / ocrStatus / createdAt. Stored values are the model reading of the page, so each side is coerced before comparing: full-width characters are normalized, then currency marks (¥ ¥ $ ₩ € £ 円 元 원 and friends), digit separators and accounting negatives ((1,200), △1,200, a trailing hyphen) are stripped. If what remains is a plain decimal on both sides the comparison is numeric; otherwise it falls back to string comparison. Dates, phone numbers and values like 12% are not treated as numbers.
sortstring | string[]optional
[Sheets only] Sort spec (e.g. total:desc or -invoice_date). Repeatable for tie-breaks. Values coerce the same way as in where: numeric ordering when both parse as numbers, string ordering otherwise.
selectstringoptional
[Sheets only] Comma-separated columns to return (projection), e.g. vendor,total.
limitinteger 1..500optional
[Sheets only] Max rows to return. Does not paginate doc bundle pages.
offsetintegeroptional
[Sheets only] Rows to skip for pagination. Pair with response.nextOffset.
boxes"0" | "1" | "true" | "false"optional
[Sheets only] Set 0 / false to drop the row's cells map (coordinates + verification) for a lean payload; values / review / image stay. select= filters values keys and cells paths whose first segment matches.
Request
1
2
3
4
5
6
7
8
# 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"folder" | "sheet" | "doc" | "memo" | "img"
The item's kind. Which of the fields below are present depends on it.
pathstring
The item's path.
namestring
Display name (all kinds except folder).
columnsarray<ColumnSpec>
[Sheet] The sheet's column schema (same shape as POST /create).
totalinteger
[Sheet] Total rows in the sheet / [doc] page count.
matchedinteger
[Sheet] Rows that passed where.
offset / limit / nextOffsetinteger | null
[Sheet] Paging state. Pass nextOffset as the next offset (null when exhausted).
rowsarray<Row>
[Sheet] The rows, in ascending createdAt order — the same order POST /edit and POST /remove use for row: N.
rowKeystring
The row's stable key — pass it as row to POST /edit.
namestring
Original file name.
createdAtinteger (epoch ms)
Upload time — the basis of the default row order.
imageUrlstring | null
Source image URL.
ocrStatus"pending" | "done" | "failed"
OCR status.
valuesobject | null
The extracted values, keyed by column name — the same pure user data as data.values on POST /ocr/fields. null before OCR.
cellsmap<path, Cell>
The same coordinate/verification map as POST /ocr/fields. Omitted with boxes=0.
reviewobject
The same verification summary as POST /ocr/fields (unit: "field").
image{ width, height }
The size of the page as read, in pixels — the frame the coordinates live in. It can differ from the file you sent.
mode"markdown" | "text"
[doc] The bundle's conversion mode.
pagesarray<Page>
[doc] The pages, in upload order. Each carries values ({ markdown, elements } or { text, blocks } by mode) plus cells + review + image — the same v2 structure as POST /ocr/markdown and /ocr/text. Pages not yet OCRed carry values: null only.
pageKeystring
The page's stable key.
namestring
Original file name.
imageUrlstring | null
Source image URL.
ocrStatus"pending" | "done" | "failed"
OCR status.
valuesobject | null
{ markdown, elements } for mode=markdown; { text, blocks } for mode=text.
cellsmap<path, Cell>
The same coordinate/verification map as POST /ocr/markdown and /ocr/text (elements[3] / blocks[7] …).
reviewobject
The same verification summary (unit: "element" / "block").
image{ width, height }
The size of the page as read, in pixels — the frame the coordinates live in. It can differ from the file you sent.
itemsarray
[folder] The children ({ path, name, type, uniqueKey? }).
textstring
[memo] The memo body.
imageUrl / ocrStatusstring | null
[img] Source image URL and OCR status.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 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" }
#
POST/createBearer

Create folder / sheet / doc / memo

Create a folder, sheet, doc bundle, or memo under the parent path. Sheets carry an OCR schema (columns) and prompt; doc bundles carry a mode.

Body parameters

pathstringrequired
Parent folder path.
type"folder" | "sheet" | "doc" | "memo"required
Kind of item to create. doc is a document bundle (.md / .txt).
namestringrequired
Display name.
textstringoptional
Memo body (type=memo only).
columnsarray<ColumnSpec>optional
Sheet OCR schema (type=sheet). Every upload into the sheet is extracted against it.
idstringoptional
Stable column id. Generated automatically when omitted.
namestringrequired
Column name — extracted values land in the row under it.
type"string" | "number" | "integer" | "date" | "array"required
The value shape — string for a single value, array for repeating line items whose cells you declare with children. Declaring number / integer / date adds a parsed normalized value to each cell on every upload, and a value that won't parse raises reason "type_mismatch" — same semantics as FieldSpec.type on /ocr/fields, and the type never reaches the model.
descriptionstringoptional
A hint for where the value sits (e.g. "to the right of Total").
childrenarray<ColumnSpec>optional
Child fields for an array column — the cells of each line item.
requiredbooleanoptional
A column marked true shows up in that row's review.flagged with reason "missing" whenever its value comes back empty or is not read — applied on every upload into the sheet, and extraction is unchanged. Set it only on values the document always prints.
labelstring | string[]optional
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 (v64). string columns only; fires only when the label is printed exactly once, and it is never shown to the model — text is unchanged, only the coordinate anchor moves. Labels spanning several words ("消費税(8%)") work as written.
nearstring | string[] | { terms, match }optional
The vocabulary expected to be printed beside the value (e.g. ["御中", "様"] for an addressee). On every upload the check runs over EVERY occurrence of the value, raising reason "near_mismatch" (no occurrence sits beside the vocabulary) or "near_ambiguous" (one does, but the coordinates landed on a different copy); when the term is absent from the page the check abstains and review.notes carries issue: "near_unresolved". Pass { terms, match } to say where a term may sit inside a printed word (boundary default / suffix / prefix / standalone / anywhere) (v85). Same semantics as FieldSpec.near — never shown to the model.
not_nearstring | string[] | { terms, match }optional
The mirror of near — vocabulary the value must NOT sit beside (declare ["登録番号", "TEL", "〒"] on an addressee column). Sitting beside one raises reason "near_conflict". On forms with no addressee line 御中 is never printed and near can only abstain; this is what reaches those (v85). Same semantics as FieldSpec.not_near — never shown to the model.
patternstring | string[]optional
A regex the value must satisfy (string columns only, JSON-Schema partial-match — anchor with ^…$ for the whole value). Pass an array for any-one-matches. Matching runs on the width-folded value; breaking it raises reason "pattern_mismatch". Never shown to the model.
min / maxnumberoptional
Inclusive bounds for a number / integer column, checked against the normalized number. Outside the range raises reason "out_of_range".
enumstring[]optional
The set of allowed values (string columns only) — a vendor master's company names, a unit list (袋 / 本 / 個). A value outside the set raises reason "pattern_mismatch". The only handle on the class where both engines agree on the same misread (冊→申).
review"normal" | "off"optional
Set "off" to withhold the reasons the engine infers for this column (text_mismatch, …). It cannot silence a rule you declared (missing, or a pattern / min / max / enum / near violation).
promptstringoptional
Optional prompt that guides extraction for this sheet.
mode"markdown" | "text"optional
Doc bundle conversion mode (type=doc, default markdown). markdown preserves layout; text keeps the raw text.
Request
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 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

pathstring
The created item's path. For sheet / doc / memo the uniqueKey is baked into it.
type"folder" | "sheet" | "doc" | "memo"
What was created.
uniqueKeystring
Stable key independent of the name (non-folder). Use it as a path segment in /view and /upload from here on.
Response
1
2
3
4
5
6
7
8
// 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.
#
POST/uploadBearer$0.05 (tax incl.) × N

Upload images

Upload one or more images into a sheet or a doc bundle. multipart/form-data. Async by default (returns jobs, completion via webhook).

Form fields (multipart)

pathstringrequired
Target sheet or doc bundle path. For a doc bundle, its mode (markdown / text) decides how each page is converted.
filesfile (repeatable)required
Image file(s). For multiple, repeat the files field. Max 20 files per request, 20MB per file, and 28MB for the request as a whole (over that returns 413).
waitbooleanoptional
Set true for sync mode (waits up to 30s per image; anything slower comes back as status:"pending"). Suitable for single-file uploads.
Request
1
2
3
4
5
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

pathstring
The target path.
jobsarray<Job>
Async mode (the default). One job per file; completion arrives via the ocr.completed webhook or by polling GET /jobs/{jobId}.
uniqueKeystring
Stable key of the created row / page.
originalNamestring
Original file name.
jobIdstring
Job id for GET /jobs/{jobId}.
status"pending"
Always pending at accept time.
resultsarray<Result>
Returned instead of jobs when wait=true. On top of the Job properties, finished entries carry mode and result — the same v2 structure as GET /jobs ({ values, cells, review, image }). Anything not finished within 30s stays status: "pending"; poll /jobs for it.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 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"
    }
  }
}
#
POST/editBearer

Edit sheet cell or memo

Update a sheet cell value or memo body. anyOf: (path, row, column, value) or (path, text). Only sheets and memos are editable — doc bundles (.md / .txt) are the OCR reading itself and are rejected with 400.

Body parameters

pathstringrequired
Target sheet or memo path.
rowinteger | stringoptional
Integer index (negatives count from end) or rowKey string. Required for sheet edits.
columninteger | stringoptional
Integer column index or column name / id. Required for sheet edits.
valueanyoptional
New cell value. Required for sheet edits.
textstringoptional
New memo body. Required for memo edits.
Request
1
2
3
4
5
6
7
8
9
10
11
# 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

okboolean
true means the change is applied.
patchedobject
What was applied — { row, column, value } for a sheet edit.
Response
1
{ "ok": true, "patched": { "row": "img_abc", "column": "amount", "value": "12000" } }
#
POST/removeBearer

Delete (cascade)

Delete a folder / sheet / memo / image. Folder deletes cascade through metadata, flat entries, and Storage.

Body parameters

pathstringrequired
Path of the item to delete.
Request
1
2
3
4
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

okboolean
true means the delete finished (folders cascade through their contents).
Response
1
{ "ok": true }
#
GET/jobs/{jobId}Bearer

Poll OCR job status

Check the status of a jobId returned by POST /upload (async). Use this if you don't consume webhooks.

Path parameters

jobIdstringrequired
From the /upload response (jobs[].jobId).
Request
1
2
curl https://api.space-ocr.com/jobs/job_xxx \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

jobIdstring
The job id.
status"pending" | "done" | "failed"
Processing state. failed is auto-refunded.
uniqueKeystring
Stable key of the created row / page.
pathstring
The item's path.
sheetRef / docRefstring | null
sheetRef carries the sheet's uniqueKey, docRef the bundle's — whichever was the target (the other is null).
mode"sheet" | "markdown" | "text"
Output shape, decided by the upload target.
resultobject
Present only when status is done. { values, cells, review, image } — the same v2 structure as the OCR endpoints (/ocr/fields, /ocr/markdown, /ocr/text) regardless of mode; only the inside of values follows mode. The ocr.completed webhook's data.result is the same shape.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "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 }
  }
}
#
GET/amountBearer

Account balance and quota

Returns the current balance and available quota.
Request
1
2
curl https://api.space-ocr.com/amount \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

freeobject
The monthly free quota.
used / limit / remaininginteger
Used / cap / remaining for the current cycle.
cycleStartinteger (epoch ms)
Cycle start.
cycleEndinteger (epoch ms)
Cycle end (resets here).
flatfeeobject
The flat-fee plan. When not subscribed it is enabled: false with no other properties.
enabledboolean
Whether subscribed.
used / limit / remaininginteger
Used / cap / remaining for the current cycle.
cycleStart / cycleEndinteger (epoch ms)
The cycle window.
nextBillingAtinteger (epoch ms)
Next billing time.
interval"monthly"
Billing interval.
renewalboolean
Whether it auto-renews.
planstring
Plan name (e.g. "pro").
balanceinteger

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.

currency"scans"
The balance unit.
perCallCostinteger
Scans consumed per call (1).
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "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).
#
GET/health

Service health

Public health check (no auth required). It also reports two changelogs — this API's own (the top-level version and changelog) and that of the engine currently running (version, deploy time and changelog under engine). If you measure the engine (error rates, flag volumes, re-run stability), record both versions beside your measurements: when a number moves later, it tells you whether it was your change or one of ours. The request/response STRUCTURE is a stable contract; the changelogs only carry additive keys and content-level shifts.
Request
1
curl https://api.space-ocr.com/health

Response fields

status"ok"
ok when the service can respond.
versionstring
The public API's contract version (currently v2.8). The engine's version is engine.version.
timeinteger (epoch ms)
Server time.
changelogarray

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.

engineobject | null
The identity of the OCR engine currently running: version (the engine version, e.g. v81) / build.sha and build.deployed_at (the deployed commit and time) / changelog (the history of caller-observable changes, newest first — each entry carries version, date, changes[]). Served through a 5-minute cache, so right after a deploy it may lag by up to 5 minutes. When the engine is temporarily unreachable it is null — that is a separate matter from the API's own liveness, so status stays ok.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "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

Register one space-wide Webhook URL and every event is delivered with an HMAC signature. Configure via Developer → Webhooks or the management endpoints below.
#

Events

All events share the same envelope: event / deliveryId / occurredAt / apiVersion / data.
item.createdeventoptional
Folder/sheet/memo created via /create
upload.receivedeventoptional
Image received via /upload
ocr.completedeventoptional
OCR finished. data.mode is the output shape (sheet / markdown / text); data.result holds it
ocr.failedeventoptional
OCR failed (auto-refunded)
webhook.testeventoptional
Manual test triggered by /webhook/test

Payload example — ocr.completed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "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 }
    }
  }
}
mode is decided by the upload target — "sheet" for a sheet, "markdown" / "text" for a doc bundle. result is the same { values, cells, review, image } (v2) as GET /jobs; only the inside of values follows mode (sheet: the field values / markdown: { markdown, elements } / text: { text, blocks }). For a doc bundle sheetRef is null and docRef carries the bundle's uniqueKey.
#

Delivery headers

Receivers see the headers below. Signature and Timestamp are needed for verification.
1
2
3
4
5
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/json
#

Signature verification

X-Spaceocr-Signature is `t=<unix_ms>,v1=<hex>`. Canonical string: `${t}.${rawBody}`. Algorithm: HMAC-SHA256. Reject if timestamp drifts more than 5 minutes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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

Non-2xx triggers exponential backoff (1m → 5m → 30m → 2h) for up to 5 attempts total. 5xx / 408 / 429 / timeout retry; other 4xx go dead immediately. Delivery logs retained 30 days.
For manual redelivery, see POST /webhooks/deliveries/{deliveryId}/redeliver below.
#
GET/webhookBearer

Get webhook configuration

Returns the currently configured space-wide webhook URL and state.
Request
1
2
curl https://api.space-ocr.com/webhook \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

configuredboolean
Whether a webhook is registered. When false, no other fields are present.
urlstring
Delivery URL.
activeboolean
Whether delivery is enabled.
secretMaskedstring
The signing secret masked to its last 4 characters. The plaintext is returned only once, at mint or rotation.
createdAt / updatedAtinteger (epoch ms)
When registered / last updated.
Response
1
2
3
4
5
6
7
8
9
10
11
{
  "configured": true,
  "url": "https://example.com/hooks/space-ocr",
  "active": true,
  "secretMasked": "••••a1b2",
  "createdAt": 1716700000000,
  "updatedAt": 1716700000000
}

// when nothing is configured
{ "configured": false }
#
PUT/webhookBearer

Create or update webhook

Register or update the space-wide webhook URL. Use rotateSecret to re-issue the signing secret.

Body parameters

urlstring (uri)required
Delivery URL.
activebooleanoptional
Enable delivery (default true).
rotateSecretbooleanoptional
When true, rotate the signing secret and return the new value. On first registration a secret is minted even without this flag, and is returned in plaintext that once.
Request
1
2
3
4
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 / url / active / secretMasked / createdAt / updatedAt
The same fields as GET /webhook.
secretstring
Returned in plaintext only this once, at mint or rotation. It cannot be fetched again — store it immediately.
Response
1
2
3
4
5
6
7
8
9
{
  "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
}
#
DELETE/webhookBearer

Remove webhook

Remove the configured webhook. No further events will be delivered.
Request
1
2
curl -X DELETE https://api.space-ocr.com/webhook \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

okboolean
true means removed — no further events will be delivered.
Response
1
{ "ok": true }
#
POST/webhook/testBearer

Send a test event

Immediately send a webhook.test event to the configured URL. Useful for verifying the receiver.
Request
1
2
curl -X POST https://api.space-ocr.com/webhook/test \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

okboolean
true means the test was enqueued.
deliveryIdstring
The minted delivery id — track the outcome via /webhooks/deliveries/{deliveryId}.
Response
1
{ "ok": true, "deliveryId": "dlv_xxx" }
#
GET/webhooks/deliveriesBearer

List recent deliveries

Returns recent webhook delivery logs. Useful for debugging.

Query parameters

status"pending" | "success" | "dead"optional
Filter by delivery state.
limitinteger 1..200optional
Max entries to return. Default 50.
Request
1
2
curl https://api.space-ocr.com/webhooks/deliveries \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

itemsarray<Delivery>
Delivery logs, newest first. Retained for 30 days.
deliveryIdstring
The delivery id.
eventstring
Event name (e.g. ocr.completed).
urlstring
Where it was sent.
path / uniqueKeystring
The subject item's path and key (event-dependent).
status"pending" | "success" | "dead"
pending awaits retry; dead means all attempts are exhausted.
attemptsinteger
Attempts so far.
lastAttemptobject
Details of the last try — { at, attemptIndex, responseStatus, error, durationMs, responsePreview }.
occurredAtinteger (epoch ms)
When the event occurred.
nextAttemptAtinteger | null
Next retry time, or null.
completedAtinteger | null
When it succeeded.
Response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "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/webhooks/deliveries/{deliveryId}Bearer

Get delivery detail

Returns the full payload and attempt history for a delivery.

Path parameters

deliveryIdstringrequired
From /webhooks/deliveries.
Request
1
2
curl https://api.space-ocr.com/webhooks/deliveries/dlv_xxx \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

deliveryIdstring
The delivery id.
eventstring
Event name.
occurredAtinteger (epoch ms)
When the event occurred.
payloadobject
The full event body as delivered (including the envelope's data).
attempts[{ at, responseStatus, ok }]
The attempt history.
Response
1
2
3
4
5
6
7
8
9
{
  "deliveryId": "dlv_xxx",
  "event": "ocr.completed",
  "occurredAt": 1716700000000,
  "payload": { /* full event body */ },
  "attempts": [
    { "at": 1716700000000, "responseStatus": 200, "ok": true }
  ]
}
#
POST/webhooks/deliveries/{deliveryId}/redeliverBearer

Manually redeliver

Manually redeliver a failed webhook.

Path parameters

deliveryIdstringrequired
deliveryId to redeliver.
Request
1
2
curl -X POST https://api.space-ocr.com/webhooks/deliveries/dlv_xxx/redeliver \
  -H "Authorization: Bearer YOUR_API_KEY"

Response fields

okboolean
true means the redelivery was enqueued.
deliveryIdstring
The same id is reused (no new id is minted). The delivery's status returns to pending and the new try is appended to attempts.
Response
1
2
3
4
{ "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

The MCP server exposes this API as tools an AI agent can call. Beyond the three reading tools, an agent can create folders and sheets, upload images into them, and query the stored rows. It calls the same REST routes underneath, so the billing is identical.
#

Connect

Nothing to install. Clients that let you set a header send the API key as a bearer token. For clients that don't — claude.ai, Claude Desktop, Claude mobile — add the URL as a custom connector and an OAuth consent page asks which API key it should act with. Either way the key is used for that request only and is not stored server-side.
1
2
3
# 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)

1
2
3
4
5
6
7
8
{
  "mcpServers": {
    "space-ocr": {
      "url": "https://mcp.space-ocr.com/mcp",
      "headers": { "Authorization": "Bearer YOUR_API_KEY" }
    }
  }
}
Any other client connects the same way — this is a standard Streamable HTTP endpoint. Send images as an https:// URL the server can fetch, or as base64 / a data URI.
#

Tools

Three reading tools and eight workspace tools. Billing matches the REST route each one calls: only reading and uploading images spend credits.
ocr_extractNamed fields from one image, using the fields schema you define or autoFields to have one proposed.1 credit
ocr_markdownLayout-preserving Markdown, with per-element coordinates.1 credit
ocr_textPlain text in reading order, with per-block coordinates.1 credit
space_listBrowse the tree (GET /space).free
space_viewRead an item; query sheets with where / sort / select / limit (GET /view). Coordinates come back only with boxes: true.free
space_createCreate a folder, sheet, doc bundle, or memo (POST /create).free
space_uploadUpload up to 20 images into a sheet or bundle (POST /upload).1 credit/page
space_jobCheck an async upload job (GET /jobs).free
space_editCorrect a sheet cell or rewrite a memo (POST /edit).free
space_balanceRemaining free quota, plan allowance and balance (GET /amount).free
space_deleteDelete an item (POST /remove; folders cascade). Two steps: called without confirm it deletes nothing and returns what would go plus a signed token. Show that to the user, then call again with the token to actually delete. The root path is refused.free

Deleting takes two steps

Call space_delete without `confirm` and nothing is deleted: you get the target, how many folders, sheets, document bundles, memos and images sit under it, a sample, and a `confirm` token. The token is signed against the caller's API key and that exact path, so a model cannot invent one — there is no way to skip showing the user first. Once they agree, call again with the token. Tokens last 10 to 20 minutes, and the root path is always refused.
Deletion cannot be undone, and deleting a folder takes the images inside it with it.
Alongside the tools, the server publishes MCP resources and prompts. The resources (space-ocr://guide/schemas · /verification · /queries · /workflows) cover designing a schema, reading the verification flags, writing filters, and running a batch — loaded only when an agent needs them. The prompts (file_documents · review_flagged · ask_documents) are ready-made workflows. Both are optional parts of MCP, so client support varies.