用于提取发票数据的 API
space-ocr 发票数据提取 API 开发者指南:用 curl 与 Python 调用 POST /ocr/fields、声明 fields 结构或使用 autoFields,以及每个值的来源坐标(box·quad)与 review 契约。
从一张发票里抽取结构化数据——供应商、发票号、日期、行项目金额、税额——是最常见的文档自动化需求之一,也是手写最折腾的一类。靠正则去匹配 OCR 文本,供应商一改版式就全盘失效;模板匹配类工具又要你给每个供应商手动框选区域。你真正想要的,是一个能读懂任意版式、返回干净的强类型字段,并且——这点至关重要——能告诉你每个值在页面上的来源位置、从而让结果值得信任的发票数据提取 API。
最后这点才是关键。一个只丢回 total: 2,045、却不带任何溯源信息的发票提取接口,放进应付账款(accounts payable)流程里就是个隐患。本指南会带你走一遍 space-ocr 的 POST /ocr/fields 接口:一次同步调用,传入一张发票图片,套用你声明的字段结构(或交给 autoFields 自动提议),即可返回每个值及其来源坐标与明确的校验判定。
先看输出,再写代码
下面是一张真实解析后的收据。把鼠标悬停在任意字段上,图片上对应的框就会高亮——那个框正是该值被读取出来的位置,而每个值都带着自己的校验判定与佐证。发票的处理方式完全一致:你提取的每个字段,都会落回它原本来源的像素上。

Each value with a box carries a verified on-page location — in data.cells[path], that is box + 4-point quad + evidence.match_ratio — on a 0–1000 normalized grid (0,0 top-left → 1000,1000 bottom-right), the same shape the live API returns. Hover a field to trace it back to the pixels it came from.
鉴权与基础 URL
公开 API 只有一个统一基础地址——https://api.space-ocr.com——没有 /v1 之类的路径版本号。每个请求都用 HTTP Bearer token 鉴权,密钥以 spocr_ 为前缀:
Authorization: Bearer spocr_xxxxxxxxxxxxxxxx密钥缺失或无效会返回 401(error.code: "invalid_api_key")。403 的含义完全不同:密钥本身有效,但访问的资源不在该密钥的范围内(例如另一把密钥创建的 job)。每个响应都带有一个 X-Request-Id 头(格式为 req_xxx),建议记录下来用于排查支持问题。完整规范以 OpenAPI 3.1 的形式发布在 GET /openapi.json,如果你想直接生成客户端代码可以用它。
最简单的调用:显式声明发票字段结构
最快的方式是把你要的字段直接写出来。fields 接收一个由 FieldSpec 对象组成的数组,响应会严格按照这份声明的形状返回——不用挑模板,也不用画框。imageType 是必填参数,用来说明 image 的承载方式:"url" 或 "base64"。
声明标量 type 不只是记录意图。把 invoice_date 声明为 "date"、金额字段声明为 "number",会在原始读数旁边多出一层确定性的 data.normalized。invoice_no 上的 required: true 意味着值为空或整个缺失时,它会出现在复核清单里,而不是以空字符串悄悄通过。pattern 是你自己的编号规则;匹配方式与 JSON Schema 一样是部分匹配,要校验整个值请用 ^…$ 锚定。
如果你还不知道该声明什么,就不要传 fields,改传 autoFields: true,模型会从文档本身提议一份结构。这适合探索一份陌生的供应商单据;等字段名稳定下来,再把它们移进显式的 fields 数组,让响应结构不再随调用变化。
curl -X POST https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer spocr_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/invoices/inv-4471.jpg",
"imageType": "url",
"fields": [
{ "name": "vendor", "type": "string" },
{ "name": "invoice_no", "type": "string", "required": true,
"pattern": "^[A-Z0-9-]+$" },
{ "name": "invoice_date", "type": "date" },
{ "name": "subtotal", "type": "number" },
{ "name": "tax", "type": "number" },
{ "name": "total", "type": "number" },
{ "name": "line_items", "type": "array",
"children": [
{ "name": "description", "type": "string" },
{ "name": "qty", "type": "number" },
{ "name": "unit_price", "type": "number" }
]
}
]
}'驼峰命名(camel-case)是规范写法。 参数为 imageType 与 autoFields。旧的下划线命名(snake_case)别名(image_type、auto_fields)仍可使用,但已弃用(deprecated)——新代码请优先使用驼峰命名。
响应结构
调用成功会返回 { status: "success", data: { ... } }。data 分为四块,每块只做一件事:
data.values—— 业务数据本身,形状与你声明的fields完全一致,不掺杂其他内容。data.cells—— 以路径(total、line_items[0].unit_price)为键的扁平映射。每个 cell 带有基于 0–1000 归一化网格(0,0 = 左上角,1000,1000 = 右下角)的轴对齐矩形box{ xmin, ymin, xmax, ymax }、跟随页面倾斜的四点quad(所以哪怕是歪着拍的手机照片也能干净地框住),以及verified、review、evidence。换算成像素以data.image为基准:pixel_x = box.xmin / 1000 × data.image.width。data.review—— 汇总信息:unit: "field"、declared/returned/boxed/verified各项计数、flagged({ path, reasons }的数组)以及by_reason直方图。需要人工关注的条数就是flagged.length,没有额外的计数器;by_reason统计的是全部理由而非每条一次,因此其总和会大于等于flagged.length。data.normalized—— 与values同形的稀疏树,只装那些声明了标量类型或pattern的字段的确定性解析结果,绝不会覆盖values。
verified 是判定,而不是字符匹配分数:只要 review 里带有理由(无论哪一类)就是 false;校验跑过且没有任何标记则为 true;本就没有可校验对象时(例如整行的并集)为 null。字符比对本身在 evidence.text_match 里,所以 verified: false 与 text_match: true 同时出现并不矛盾——字符对上了,但被你声明的规则拦下了。evidence 还包含 source(vision_symbol_match、token_id 等)、match_ratio,以及 printed_text(OCR 在该坐标读到的字形)。需要精确字符串比对时,请与 printed_text 对照。
理由代码属于 API 契约词汇,不做翻译:text_mismatch、missing、pattern_mismatch、type_mismatch、out_of_range、low_ratio、overwide_box 等。reasons 始终是数组,按排序排列,第 0 项为主要理由——请为你处理的代码建立映射,并为未知代码保留一条通用提示。
{
"status": "success",
"data": {
"values": {
"vendor": "Acme Supply Co.",
"invoice_no": "INV-4471",
"invoice_date": "2026/06/18",
"subtotal": "1,859",
"tax": "186",
"total": "2,045",
"line_items": [
{ "description": "Steel bracket 40mm", "qty": "12", "unit_price": "98" }
]
},
"cells": {
"total": {
"box": { "xmin": 595, "ymin": 974, "xmax": 781, "ymax": 1000 },
"quad": [
{ "x": 594, "y": 975 }, { "x": 781, "y": 972 },
{ "x": 781, "y": 998 }, { "x": 595, "y": 1000 }
],
"verified": true,
"review": null,
"evidence": {
"text_match": true,
"source": "vision_symbol_match",
"match_ratio": 0.93,
"printed_text": "2,045"
},
"normalized": { "value": 2045, "type": "number", "method": "deterministic" }
},
"line_items[0].unit_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,
"printed_text": "9B"
}
}
},
"review": {
"unit": "field",
"declared": 9,
"returned": 9,
"boxed": 9,
"verified": 8,
"flagged": [
{ "path": "line_items[0].unit_price", "reasons": ["text_mismatch"] }
],
"by_reason": { "text_mismatch": 1 }
},
"normalized": {
"invoice_no": "INV-4471",
"invoice_date": "2026-06-18",
"subtotal": 1859,
"tax": 186,
"total": 2045,
"line_items": [ { "qty": 12, "unit_price": 98 } ]
},
"image": { "width": 1654, "height": 2339 }
}
}坐标并非照搬模型给出的文字。 语言模型会返回每个值的文本——以及它用到了哪些词元(word token)的提示——但从不返回框本身。引擎随后会把这段文本逐字符地与视觉 OCR 在页面上实际检测到的符号进行匹配;evidence.match_ratio 就是匹配上的比例,框则落在这些字符真正来源的像素上。模型的词元提示可能带噪声(它有时会在重复的行之间把它们搞混),所以系统用列一致性与行一致性检查来验证它们,而不是盲目采信。这个比例只是佐证,不是判定——判定由 verified 与 review 给出。完整原理见为什么边界框让 OCR 可审计。
会变成复核信号的声明
FieldSpec 除了给字段命名,还能声明“什么样的值才算好”,而每一项声明都对应一个复核理由。required: true 会在值为空或压根没返回时触发 missing——这是字符比对本身永远看不到的唯一一类失败。pattern 触发 pattern_mismatch,enum 在值落到集合之外时同理,min / max 针对归一化后的数字触发 out_of_range,标量 type 在无法解析时触发 type_mismatch。
这些都不会传给模型。声明并不会让抽取更准——值在有无声明的情况下都一样返回。声明决定的是哪些路径会进入 data.review.flagged,以及在 label 的情形下引擎把坐标锚定在哪里。让“读”和“查”彼此独立正是要点所在:唯有如此,两者的一致才具有分量。
真正用来引导模型的是 description:用平实的自然语言说明要抓取什么、怎么抓。而 type: "array" 配上 children,正是抽取重复明细行(line items)的方式——一个子结构对应多行,每一行都可以用 line_items[0]、line_items[1] 这样的路径定位。(我们在从发票中提取明细行一文中对此做了深入讲解。)
import requests, base64
with open("invoice.jpg", "rb") as f:
b64 = base64.b64encode(f.read()).decode()
resp = requests.post(
"https://api.space-ocr.com/ocr/fields",
headers={"Authorization": "Bearer spocr_xxxxxxxxxxxxxxxx"},
json={
"image": b64,
"imageType": "base64",
"fields": [
{"name": "vendor", "type": "string",
"description": "Supplier / billing company name"},
{"name": "invoice_no", "type": "string", "required": True,
"description": "Invoice number as printed"},
{"name": "invoice_date", "type": "date"},
{"name": "total", "type": "number",
"description": "Grand total"},
{"name": "line_items", "type": "array",
"description": "One row per line on the invoice",
"children": [
{"name": "description", "type": "string"},
{"name": "qty", "type": "number"},
{"name": "unit_price", "type": "number"},
]},
],
},
timeout=200,
)
data = resp.json()["data"]
# 印在纸面上的值,以及它旁边确定性的解析结果
print(data["values"]["total"], data["normalized"].get("total"))
# 复核队列:每个需要人工查看的路径一条
for item in data["review"]["flagged"]:
cell = data["cells"].get(item["path"])
print(item["path"], item["reasons"][0], cell["box"] if cell else None)values 是读数,不是逐字节的副本。 印为 7,855 的合计会以字符串 "7,855" 返回——不做摘要也不改写,正因如此这个值才能锚定到坐标上。但它终究是模型读出来的文本,而字符比对会先折叠全角形式、括号与空白再作比较,所以像 (税抜) → (税抜) 这样的改写会通过。需要精确字符串比对时,请与 cells[path].evidence.printed_text(OCR 在该坐标读到的字形)对照。解析后的形态(ISO 日期、去掉分隔符的数字)放在 data.normalized,绝不覆盖 values。你在网页界面里看到的 ¥ 只是装饰,不属于值本身。引擎只接受栅格图像——JPEG、PNG、GIF、BMP、TIFF、WebP——并会自动转换为 RGB。
走异步:批量上传、任务与 Webhook
POST /ocr/fields 是同步的,非常适合在一次请求/响应循环里处理单张发票。它会在读取页面期间保持连接,处理时间上限为 180 秒;超过就会返回 504 与 error.code: "ocr_engine_timeout",且该次调用不计费。触到上限的原因通常是版面密度而非像素数,所以对策是一页一图,或者走下面的异步路径。
如果是一整个文件夹的发票,可以用 POST /upload 把它们提交到一个表格(sheet)里(multipart 形式、可重复的 files,每次请求最多 20 个文件)。默认情况下它会立即返回一个 jobs 数组:
{ "path": "...", "jobs": [ { "uniqueKey": "...", "jobId": "...", "status": "pending" } ] }之后你有两种方式得知结果:轮询 GET /jobs/{jobId},或者注册一个 webhook。Webhook 是每个空间一个 URL,通过 X-Spaceocr-Signature 头做 HMAC-SHA256 签名。你会关心的事件有 upload.received、item.created、ocr.completed(其 data.result 以同样的 values / cells / review / image 结构携带提取结果)以及 ocr.failed。在信任任何 payload 之前,请务必先验证签名。
幂等性、请求追踪与限流
有几个请求头能让生产流程安全地重试:
| Header | 用途 |
|---|---|
Idempotency-Key | 在 /ocr/fields、/create 与 /upload 上受理。用相同的 key 重发会在 24 小时内重放缓存的响应(X-Idempotent-Replay: true)——重试安全、不会重复扣费。它是重试保护,不是存储手段。 |
X-Request-Id | 每个响应都会返回(req_xxx);记录下来以便排查问题。 |
X-RateLimit-Remaining | 该密钥在当前这一分钟内剩余的可调用次数。 |
限流为 每个密钥 60 次/分钟、每个 uid 600 次/分钟。超出后会返回 429,带 error.code: "rate_limited",等待秒数放在 Retry-After 响应头里——不要立刻重发,请按这个值退避(back off)。
做容量规划时可以参考:生产流量上观测到的耗时约为 p50 7.2 秒、p90 10.5 秒。这是观测分布而非 SLA,字段多、版面密的发票会高于这个区间。
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded",
"requestId": "req_8fa2c1"
}
}从提取到可查询的表格
一旦发票被提取进表格,你就不必为了再读取它们而重跑 OCR。GET /view 会对已存储的行做服务端查询——where、sort、select、limit、offset——不收费、也不会重新提取。每一行都以与直接调用相同的 values / cells / review / image 结构返回;想要更精简的 payload,可以加上 boxes=0 去掉 cells 映射。从那里你还能导出为 CSV(UTF-8 BOM,所以 Excel 和中日韩文本都能正常打开)——参见把扫描文档转成 CSV。
定价
POST /ocr/fields 每次调用收费 $0.05,POST /upload 为 $0.05 × N 张图片。失败不收费——400 invalid_image 与 504 ocr_engine_timeout 根本不会走到计费环节,而 502 引擎错误或 ocr.failed 事件会自动退款。只读接口(GET /space、/view、/jobs、/amount、/health)免费。免费档为每月 100 点数、无需信用卡;付费档从 Starter 每月 $19 起,Pro 为每月 $39——最新档位请见定价页。
如何用 API 从发票中提取数据
- 获取 API 密钥登录后生成一个以 spocr_ 为前缀的密钥。每个请求都通过 Authorization: Bearer 头向 https://api.space-ocr.com 鉴权。
- 准备发票图片引擎只读栅格图像——JPEG、PNG、GIF、BMP、TIFF、WebP。提供一个公开可访问的 URL,或把图片编码为纯 base64,并把必填参数 imageType 设为 'url' 或 'base64'。
- 声明你要的字段向 /ocr/fields 发送 POST 请求,传入 fields[] 数组:vendor、invoice_no、invoice_date、各金额字段,以及作为数组并带 children 的 line_items。若还不知道结构,就改传 autoFields: true。
- 读取值与复核队列业务数据取自 data.values,然后遍历 data.review.flagged。每条记录是一个路径加上它的理由;用该路径在 data.cells 中查出对应的 box、quad 与 evidence。
- 扩展到批量与查询如需处理多张发票,用 POST /upload 把它们提交到一个表格,再通过轮询 GET /jobs/{jobId} 或 ocr.completed webhook 获取结果。之后用 GET /view 查询已存储的行,或导出为 CSV。