发票 OCR API/送货单 OCR 转 CSV ── 发票数据提取 API 实战指南
把发票、送货单从手工录入和 Excel 乱码中解放出来的开发者指南。声明要提取的字段后向 POST /ocr/fields 上传图片,即可返回交易方、日期、合计、明细等结构化数据,每个值都带有原图坐标(box、quad),需要复核的字段则汇总在 review 列表里。含 curl/Python 代码、CSV 导出、Webhook 与价格。
你是不是到现在还在手动把发票、送货单一个个敲进 Excel?日期、交易方、税前、税后,还有明细的每一行 ── 一到月底,就对着堆成山的纸张较劲,把数字一格一格地誊抄。中途某一位数字错了位,合计对不上,又得从头核对一遍。这些时间,真希望能省下来。
想复制扫描出来的 PDF,结果文字根本选不中。拿去做 OCR,明细又全挤进了一个单元格,换行和列全没了。用 Excel 打开 CSV,又是一片乱码,品名压根读不出来。明明只想导进财务软件,却总是卡在这最后一步 ── 这就是天天和单据打交道的人都熟悉的「老大难」。
本文是一份开发者指南,教你把这些活儿换成一个 API。向 POST /ocr/fields 上传发票、送货单的图片,交易方、日期、合计等字段,以及明细的每一行,都会以带类型的结构化数据返回。更关键的是,返回的每一个值都附带它在原图中被识别出来的坐标(box、quad),所以你不必盲目相信提取结果,而是可以拿原件逐一比对校验。下面配合 curl 和 Python 代码,从最快上手到生产环境,完整走一遍。
先上手试试 ── 无需上传,10 秒体验
写代码之前,先看看实际输出。下面是解析一张真实小票的结果。把光标移到某个字段上,就会高亮出这个值是从图片的哪个位置读出来的。发票、送货单的表现完全一样 ── 提取出的每一个值都对应到它被读取的那块像素,而没有对上的字段会进入 data.review.flagged,作为需要复核的条目列出来。

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.
「原图 → 提取表格 → 高亮对应位置 → 导出 CSV」的流程
space ocr 的用法,归根结底就是 4 步。(1) 上传收据、发票、送货单的图片 → (2) 按固定列结构提取成一张表格,一图=一行 → (3) 点击某个值,原图的对应位置就会点亮,方便和原件核对 → (4) 直接导出 CSV 导入财务软件。先从上传一张、看着字段被自动填满开始。
鉴权与基础 URL
公开 API 只有一个基础地址 https://api.space-ocr.com ── 没有 /v1 这样的路径版本号。每个请求都用以 spocr_ 开头的密钥,通过 HTTP Bearer Token 进行鉴权。
Authorization: Bearer spocr_xxxxxxxxxxxxxxxx请求头缺失或密钥无效会返回 401(error.code: "invalid_api_key")。403 不是鉴权失败,而是你访问了该密钥权限之外的资源 ── 例如另一把密钥创建的任务。所有响应都带有 X-Request-Id(格式为 req_xxx)请求头,建议记录到日志里,方便日后联系支持时排查。如果想自动生成客户端,可在 GET /openapi.json 获取公开的 OpenAPI 规范。
最快路径 ── 声明你要提取的字段
在 fields 里用 FieldSpec 数组声明想提取的字段 ── 发票是交易方、开票日期、单据号、合计,送货单是送货日期、品名、数量、单价。name 会直接成为响应 JSON 的键,所以可以把公司内部的表结构原样抄进请求里。遇到不清楚有哪些字段的版式,也可以用 autoFields: true 让模型先提议一份 schema。图片既可以传 URL,也可以传纯 base64,并用 imageType 说明是哪一种。
声明本身不会改变提取结果。type(number / integer / date)、pattern、min / max、required 都不会传给模型,所以声不声明,values 返回的读数都一样。声明产生的是两样东西:把同一读数按该类型解析后的 data.normalized 层,以及违反规则时立起的复核理由(type_mismatch、out_of_range、pattern_mismatch、missing)。明细这类重复行不要去数行数,用 type: "array" 加 children 只声明一行的结构 ── 返回几行由页面决定,而且子字段的坐标是在各自的行内解析的,所以每一行都重复出现的「数量」「金额」表头也不会串行。
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/docs/delivery-0831.jpg",
"imageType": "url",
"fields": [
{ "name": "customer", "type": "string",
"description": "交易方(收货方)公司名称",
"near": ["御中", "様"],
"not_near": ["登録番号", "TEL", "〒"] },
{ "name": "delivery_no", "type": "string", "required": true,
"pattern": "^[A-Z]{2}-[0-9]{4,8}$",
"description": "单据号" },
{ "name": "delivery_date", "type": "date",
"label": "納品日", "description": "送货日期" },
{ "name": "items", "type": "array",
"description": "每条明细对应一个元素",
"children": [
{ "name": "name", "type": "string" },
{ "name": "qty", "type": "integer" },
{ "name": "unit_price", "type": "number" },
{ "name": "amount", "type": "number" }
] },
{ "name": "total", "type": "number", "required": true,
"label": "合計", "description": "合计金额" }
]
}'请求体参数的正式写法是驼峰式(camelCase)。 请使用 imageType / autoFields。旧的下划线写法(image_type / auto_fields)虽然也能用,但已不推荐。imageType 是必填的,必须明确写成 "url" 或 "base64" ── 系统不会根据值的形态自动判断。另外,fields 内部的属性名(required、label、pattern、near、not_near 等)属于 schema 一侧,请照 API 文档里 FieldSpec 表格的写法书写。
响应的结构 ── 每个值都带「出处」
成功时会返回 { status: "success", data: { ... } }。data 分成几层,业务数据和校验信息不会混在一起。
data.values── 与你声明的 schema 完全一致的纯业务数据。不掺入保留键,可以直接入库。data.cells── 以 path 为键的扁平坐标/校验映射。用total或items[0].amount这样的 path 查询,就能拿到该值的box({ xmin, ymin, xmax, ymax }轴对齐矩形)、quad(顺着单据倾斜方向的 4 个点)、verified(判定)、review(复核理由)与evidence(比对证据)。坐标是归一化到 0–1000的整数,换算的基准不是你上传的文件,而是data.image──pixel_x = box.xmin / 1000 × data.image.width。data.review── 单张单据的汇总,以及需要复核的字段清单flagged。它是{ path, reasons }的数组,path与cells的键语法相同,可直接查询。数量就是flagged.length。data.normalized── 只有在声明了标量类型或pattern/enum时才会出现的一层。它是与values形状完全相同的树,只有叶子被解析成了该类型。data.image── 实际读取的那一页的width/height(像素)。这是应用 EXIF 方向之后的尺寸,所以把坐标叠回图片时要以它为准。
evidence 里的 text_match(字符比对是否通过)和 match_ratio(该值的字符中在页面上找到的比例)是支撑判定的证据。与其自己定一个阈值去遍历所有字段,不如直接把 review.flagged 当作待办清单来用。
{
"status": "success",
"data": {
"values": {
"customer": "株式会社サンプル商事",
"delivery_no": "DN-100482",
"delivery_date": "令和8年8月31日",
"items": [
{ "name": "A4 复印纸", "qty": "5", "unit_price": "480", "amount": "2,400" }
],
"total": "2,400"
},
"cells": {
"customer": { "box": { "xmin": 62, "ymin": 118, "xmax": 384, "ymax": 152 },
"quad": [{"x":62,"y":118},{"x":384,"y":118},{"x":384,"y":152},{"x":62,"y":152}],
"verified": false,
"review": { "reasons": ["near_conflict"] },
"evidence": { "text_match": true, "source": "vision_symbol_match",
"match_ratio": 1.0,
"not_near": { "matched": "登録番号", "distance": 0.4 } } },
"delivery_date": { "box": { "xmin": 612, "ymin": 96, "xmax": 812, "ymax": 124 },
"quad": [{"x":612,"y":96},{"x":812,"y":96},{"x":812,"y":124},{"x":612,"y":124}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0 },
"normalized": { "value": "2026-08-31", "type": "date", "method": "deterministic" } },
"items[0].qty": { "box": { "xmin": 512, "ymin": 470, "xmax": 536, "ymax": 496 },
"quad": [{"x":512,"y":470},{"x":536,"y":470},{"x":536,"y":496},{"x":512,"y":496}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "match_ratio": 1.0 },
"normalized": { "value": 5, "type": "integer", "method": "deterministic" } },
"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 },
"normalized": { "value": 2400, "type": "number", "method": "deterministic" } }
},
"review": {
"unit": "field",
"declared": 8,
"returned": 8,
"boxed": 8,
"verified": 7,
"flagged": [
{ "path": "customer", "reasons": ["near_conflict"] }
],
"by_reason": { "near_conflict": 1 }
},
"normalized": {
"delivery_date": "2026-08-31",
"items": [ { "qty": 5, "unit_price": 480, "amount": 2400 } ],
"total": 2400
},
"image": { "width": 1654, "height": 2339 }
}
}坐标并不会盲目相信 AI 的一面之词。 语言模型返回的只是每个值的文本,并不返回坐标本身。引擎会把这段文本,与 OCR 在页面上实际检测到的符号逐字符比对 ── 所以矩形会落到这些字符真正出现的像素上。比对是否通过记录在 evidence.text_match,匹配了多少记录在 evidence.match_ratio。单元格的 verified 则位于它们之上,是 review 的镜像判定:只要立起任何一条复核理由就是 false,什么都没立且确实跑了比对就是 true,没有可比对的对象(例如行的并集)就是 null。因此 verified: false 配 text_match: true 并不矛盾,而是「字符对上了,但你声明的规则拦下了它」这种正常组合。不过,两个引擎也可能在同一个误读上达成一致,那个值就会通过 ── 出处校验和你自己的业务规则(required、pattern、enum、near)是互补的两层,生产环境两者都要跑。详情请参阅用边界框让 OCR 可审计的机制。
收件方和开票方,就印在同一页上
日本发票、送货单里最棘手的错误,不是把字读错。而是字读得完全正确,却取自错误的位置。同一张纸上印着两个公司名 ── 收件方和开票方 ── 就算取错了一边,字符比对照样一致,于是以 verified: true 通过。把交易方主数据交给 enum 也分不开,因为两边都是已登记的合法名称。
负责这一层的是 near 和 not_near。对收件方公司名,把「本应印在值旁边的词」声明为 near: ["御中", "様"],把「不该出现在旁边的词」声明为 not_near: ["登録番号", "TEL", "〒"]。如果该值的任何一处出现都不在 near 词的邻域里,就立起 near_mismatch;如果有出现在邻域里、但拿到坐标的是另一处副本,就是 near_ambiguous;如果值就坐在开票方区块的词旁边,则是 near_conflict。判定的具体依据会原样放进 cells[path].evidence.near / evidence.not_near。
当声明的 near 词在整页上都没有印出来时,near 会放弃判定,并把这件事以 issue: "near_unresolved" 记入 review.notes ── 不能因为一张不印「御中」的事务表单就惩罚它。而当事人被张冠李戴,恰恰多发生在这类版式上,能够触及它们的是 not_near。词允许出现在印刷词的哪个位置由 match 指定:默认的 boundary,以及 suffix(御中、様)、prefix(〒、TEL)、standalone、anywhere ── 正是这一项,避免让工程名「中野様邸増築工事」里的「様」成为收件方判定的证人。两种声明都不会传给模型,所以提取到的值不会改变。请把它理解为不是替你挑对,而是让挑错的值显形的机制。
import requests, base64, csv
with open("delivery.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": "customer", "type": "string",
"description": "交易方(收货方)公司名称",
"near": ["御中", "様"],
"not_near": ["登録番号", "TEL", "〒"]},
{"name": "delivery_no", "type": "string", "required": True,
"pattern": "^[A-Z]{2}-[0-9]{4,8}$",
"description": "单据号"},
{"name": "delivery_date", "type": "date",
"label": "納品日", "description": "送货日期"},
{"name": "items", "type": "array",
"description": "每条明细对应一个元素",
"children": [
{"name": "name", "type": "string", "description": "品名"},
{"name": "qty", "type": "integer", "description": "数量"},
{"name": "unit_price", "type": "number", "description": "单价"},
{"name": "amount", "type": "number", "description": "金额"},
]},
{"name": "total", "type": "number", "required": True,
"label": "合計", "description": "合计金额"},
],
},
timeout=200, # 同步处理上限为 180 秒
)
data = resp.json()["data"]
values = data["values"]
norm_items = data.get("normalized", {}).get("items", [])
# 先取需要复核的字段。数量就是 flagged 的长度
for flag in data["review"]["flagged"]:
cell = data["cells"].get(flag["path"])
print(flag["path"], flag["reasons"], cell["box"] if cell else None)
# 明细写入 CSV:展示列用 values,计算列用 normalized
with open("delivery.csv", "w", encoding="utf-8-sig", newline="") as out:
w = csv.writer(out)
w.writerow(["品名", "数量", "单价", "金额", "金额(数值)"])
for i, row in enumerate(values.get("items", [])):
n = norm_items[i] if i < len(norm_items) else {}
w.writerow([row["name"], row["qty"], row["unit_price"],
row["amount"], n.get("amount")])values 是模型从页面上读出的字符串,并不是逐字节的复制品。 字符比对会先把全角、括号、空白折叠后再比较,所以 (税抜) 写成 (税抜) 这种程度的差异是能通过的 ── 需要精确匹配时,请使用 cells[path].evidence.printed_text,也就是 OCR 在那个坐标上读到的字符。想按数字或日期处理时,不要去改写 values,而是声明 type 并读取 data.normalized("令和8年8月31日" → "2026-08-31","2,400" → 2400)。解析是确定性的,不会额外调用模型。解析不了的叶子在 normalized 里是 null,原因放在 cells[path].normalized.error。所以在 CSV 里,展示的列取自 values,参与计算的列取自 normalized,这样分开更稳妥。
反过来也要注意:像数量栏的「一式」、付款期限的「翌月末払い」这种本来就会正式印出非数值写法的字段,一旦声明了类型,单据明明没错也会每次都以 type_mismatch 进入复核清单 ── 只给一定是数字或日期的字段声明类型。应对 CSV 乱码的做法是:要用 Excel 打开的 CSV,请用 UTF-8 BOM(utf-8-sig)导出。值里会原样带上半角 ¥(U+00A5)之类的字符,所以请保持 UTF-8,不要转成 cp932 / Shift_JIS。另外,防止明细「被挤进一个单元格」的关键就是 type: "array" + children,它会把一条明细展开成一行。
点一下文字,跳到原图对应位置
数据沉淀到表格之后,点击某个值,原图的对应位置就会点亮。这是批量抽查时最快的办法 ── 不必把整张单据扫一遍,视线直接落到那个位置。你也不需要逐一核对所有字段:只打开 data.review.flagged 里列出的条目 ── 字符没对上、违反了你声明的规则、必填却没有返回 ── 要看的位置由 cells[path] 的坐标直接指出来。
异步批量处理 ── 批量上传、任务、Webhook
POST /ocr/fields 是同步的,最适合放进请求/响应循环里做单张处理。如果要整批处理一整个发票、送货单文件夹,就对某张表格用 POST /upload(multipart 里重复 files)提交。默认会立即返回一个任务数组。
{ "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 中)、ocr.failed。在信任载荷之前,请务必先校验签名。
幂等性、请求追踪与限流
为了让生产流水线能安全重试,有几个请求头可用。
| 请求头 | 作用 |
|---|---|
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 响应头里。
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded",
"requestId": "req_8fa2c1"
}
}从提取,到可查询的表格
把发票提取进表格之后,要回头读取数据时,不必再跑一次 OCR。GET /view 会在已沉淀的行上执行服务端查询 ── where、sort、select、limit、offset ── 既不重跑 OCR,也不计费。坐标默认会一起返回,想轻量化时再加 boxes=0。比如用 where=total>=40000 只看高额发票,用 sort=-invoice_date 按从新到旧排序。从这里导出 CSV(带 UTF-8 BOM,所以 Excel 和 CJK 都能正常打开),就能用于导入财务软件 ── 详情请参阅把扫描单据转成 CSV和把收据转成 CSV。所有端点的规范都汇总在 API 文档里。
PDF 需先把页面转成图片再发送。 OCR 引擎直接解析的是栅格图像(JPEG、PNG、GIF、BMP、TIFF、WebP)。如果直接调用 API,请先把 PDF 的每一页渲染成 PNG 等图片再发送(如果是拖入 Web 应用,应用会自动把页面图像化,所以可以直接丢 PDF 进去)。与 freee、マネーフォワード(Money Forward)、弥生(Yayoi)、kintone 的对接并非官方 API 集成,而是默认通过导入导出的 CSV 来完成。另外,是否符合发票留存制度(インボイス制度)或电子账簿保存法(電子帳簿保存法),请结合各家的运营与要求自行确认(本服务并不保证满足法定要求)。
价格
POST /ocr/fields 是每次调用 $0.05(含税),POST /upload 是 $0.05 × N 张。失败不计费 ── 图片读不出来的 400(invalid_image)和超过同步处理上限的 504 本来就不计费,502 引擎错误和 ocr.failed 事件会自动退款。只读端点(GET /space、/view、/amount、/health)免费。免费额度无需信用卡,每月 100 个额度;Pro 为 $39/月。完整的方案列表见价格页面。
用 API 提取发票、送货单的步骤
- 准备 API 密钥登录后签发以 spocr_ 开头的 API 密钥,在每个请求里加上 Authorization: Bearer spocr_...。基础 URL 为 https://api.space-ocr.com。
- 准备图片(PDF 先把页面图像化)把发票、送货单准备成 JPEG/PNG 等栅格图像。如果直接调用 API,PDF 要先把每一页渲染成 PNG 再发送(拖入 Web 应用时应用会自动图像化)。图片可用 URL 或纯 base64 传入,并用 imageType 指定 url / base64。
- 调用 POST /ocr/fields把要提取的字段用 FieldSpec({name, type, description, required, label, pattern, near, not_near, children})声明在 fields[] 里。明细不要去数行数,用 type:"array" + children 只声明一行的结构,返回几行交给页面决定。对不清楚有哪些字段的版式,也可以用 autoFields: true 让模型提议 schema。
- 校验响应把 data.review.flagged 里的 {path, reasons} 当作待办清单打开,再用该 path 查 data.cells[path],确认 box、quad 坐标和 evidence(text_match、match_ratio)。声明了类型的字段还会在 data.normalized 里给出解析后的值,解析失败的原因放在 cells[path].normalized.error。
- 导出 CSV 导入财务软件把提取结果导出为带 UTF-8 BOM 的 CSV(明细作为数组行展开),交给 freee、マネーフォワード(Money Forward)、弥生(Yayoi)等的 CSV 导入。数据沉淀后,可用 GET /view 在不重跑 OCR、不计费的情况下查询。