發票 OCR API/送貨單 OCR 轉 CSV ── 發票資料擷取 API 實作指南
讓開發者擺脫發票、送貨單手動輸入與 Excel 亂碼的指南。宣告要擷取的欄位後把影像 POST 到 /ocr/fields,即可取得結構化的客戶、日期、合計與明細,每個值都附帶原始影像座標(box、quad),需要複查的欄位則整理在 review 清單裡。內含 curl、Python 程式碼、CSV 輸出、Webhook 與收費說明。
你是不是還在用手把發票、送貨單一格一格敲進 Excel?日期、客戶、未稅、含稅,還有明細的每一行 ── 一到月底就得對著堆積如山的紙張,把數字一格一格謄寫過去。中途差了一位數,合計兜不攏,又得從頭核對一遍。那段時間,真的很想省下來。
想把掃描好的 PDF 複製出來,卻發現文字根本選不起來。丟去 OCR 跑一遍,明細又全部擠進同一個儲存格,換行和欄位都不見了。CSV 用 Excel 一打開又亂碼,品名完全看不懂。明明只是想匯進會計軟體,卻每次都卡在這道關卡前 ── 這就是天天和文件打交道的人最熟悉的「日常」。
這篇文章,就是要教開發者怎麼把這些作業換成一支 API。把發票、送貨單的影像 POST 到 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) 在欄位固定的表單裡,以 1 張=1 列的方式擷取出來 → (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 不是驗證失敗,而是你碰到了該金鑰權限之外的資源 ── 例如另一把金鑰建立的 job。所有回應都會帶上 X-Request-Id(格式 req_xxx)標頭,記進 log 裡,日後向客服詢問時會比較安心。如果想自動產生用戶端,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": "合計金額" }
]
}'請求主體參數的正式名稱是駝峰式命名。 請使用 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 為鍵的扁平座標/驗證 map。用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,靠它把 1 筆明細展開成 1 列。
點一下文字,跳到原始位置
累積進表單之後,點一下值,原始影像的對應位置就會亮起。這是批次抽查時最快的方法 ── 不必把整份文件看一遍,視線會直接飛到對應位置。你也不需要逐一核對所有欄位:只打開 data.review.flagged 列出的項目 ── 字元沒對上、違反了你宣告的規則、必填卻沒有回傳 ── 要確認的位置就由 cells[path] 的座標直接指出來。
非同步大量處理 ── 批次上傳、Job、Webhook
POST /ocr/fields 是同步的,最適合放在 request/response 迴圈裡的單張處理。如果要整批處理一整個發票、送貨單的資料夾,就對表單用 POST /upload(重複 multipart 的 files)丟進去。預設會立刻回傳 job 陣列。
{ "path": "...", "jobs": [ { "uniqueKey": "...", "jobId": "...", "status": "pending" } ] }取得結果有兩種方式。輪詢 GET /jobs/{jobId},或是註冊 Webhook。Webhook 每個 space 一個 URL,所有事件都會用 X-Spaceocr-Signature 標頭做 HMAC-SHA256 簽章。值得關注的事件有 upload.received、item.created、ocr.completed(擷取結果在 data.result)、ocr.failed。在信任 payload 之前,請務必驗證簽章。
冪等性、請求追蹤、速率限制
為了讓正式環境的 pipeline 能安全地重試,有幾個標頭可以用。
| 標頭 | 作用 |
|---|---|
Idempotency-Key | 在 /ocr/fields、/create、/upload,相同金鑰的重送會重播 24 小時內的快取回應(X-Idempotent-Replay: true)── 不會重複計費,可安心重試。 |
X-Request-Id | 所有回應都會帶(req_xxx)。記進 log 供客服使用。 |
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)、弥生(彌生)、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(マネーフォワード)、彌生(弥生)等的 CSV 匯入。累積之後可用 GET /view 在不重跑 OCR、不計費的情況下查詢。