請求書OCR API/納品書OCRでCSVへ ── 請求書データ抽出APIの実装ガイド
請求書・納品書を手入力やExcel文字化けから解放する開発者向けガイド。POST /ocr/fields に取り出したい項目を宣言すると、取引先・日付・合計・明細が構造化データで返り、値ごとに元画像の座標(box・quad)と、確認が要る項目を並べた review リストが付きます。curl・Pythonコード、CSV出力、Webhook、料金まで。
請求書や納品書を、いまだに手で Excel に打ち込んでいませんか。日付、取引先、税抜・税込、そして明細の一行一行 ── 月末になると山積みの紙とにらめっこして、数字を1セルずつ転記する。途中で一桁ずれて、合計が合わなくて、また最初から照合する。あの時間を、なくしたい。
スキャンした PDF をコピーしようとしたら文字が選択できない。OCR にかけたら、明細がぜんぶ1つのセルに潰れて改行も列も失われる。CSV を Excel で開いたら文字化けして品名が読めない。会計ソフトに取り込みたいだけなのに、その手前で毎回つまずく ── これは、書類を扱う現場の「あるある」です。
この記事は、その作業を API 1本 に置き換えるための開発者向けガイドです。POST /ocr/fields に請求書・納品書の画像を投げると、取引先・日付・合計といった項目と、明細の各行が型のついた構造化データで返ってきます。しかも返ってくるすべての値に、元画像のどこから読み取ったかの座標(box・quad)が付くので、抽出結果を鵜呑みにせず原本と突き合わせて検証できます。curl と Python のコード付きで、最短経路から本番運用までひと通り見ていきます。
まずは触ってみる ── アップロード不要、10秒で体験
コードを書く前に、実際の出力を見てください。下は本物のレシートを解析した結果です。項目にカーソルを合わせると、その値が画像のどこから読み取られたかがハイライトされます。請求書・納品書もまったく同じ挙動です ── 抽出した1つ1つの値が読み取り元のピクセルに紐づき、照合が合わなかった項目は確認対象として 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 で書き出して会計ソフトに取り込む。まずは1枚を投げて項目が埋まる様子から。
認証とベース URL
公開 API のベースは https://api.space-ocr.com の1つだけ ── /v1 のようなパスバージョニングはありません。各リクエストは、spocr_ で始まるキーを使った HTTP Bearer トークンで認証します。
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 でスキーマ自体を提案させることもできます。画像は URL でも純粋な base64 でも渡せ、どちらかを imageType で明示します。
宣言は抽出そのものを変えません。type(number / integer / date)・pattern・min / max・required はモデルに渡らないので、values は宣言してもしなくても同じ読みが返ります。宣言が作るのは2つです ── 同じ読みをその型に解釈した data.normalized の層と、規則を破った項目に立つ検討理由(type_mismatch・out_of_range・pattern_mismatch・missing)。明細のような繰り返しは行数を数えず、type: "array" と children で1行分の形だけ宣言します ── 返る行数はページ次第で、子の座標は行ごとに解決されるため、全行で同じ「数量」「金額」の見出しが繰り返されても取り違えません。
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": "明細1行につき1要素",
"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 など)はスキーマ側の名前なので、API ドキュメントの FieldSpec 表のとおりに書いてください。
レスポンスの形 ── 値ごとに「出どころ」が付く
成功すると { status: "success", data: { ... } } が返ります。data は層に分かれていて、業務データと検証情報が混ざりません。
data.values── 宣言したスキーマそのままの純粋な業務データ。予約キーが混ざらないので、そのまま DB に保存できます。data.cells── パスをキーにしたフラットな座標・検証マップ。totalやitems[0].amountのようなパスで引くと、その値のbox({ xmin, ymin, xmax, ymax }の軸並行矩形)・quad(書類の傾きに沿う4点)・verified(判定)・review(検討理由)・evidence(照合の証跡)が入っています。座標は0〜1000 に正規化された整数で、換算の基準は送ったファイルではなくdata.imageです ──pixel_x = box.xmin / 1000 × data.image.width。data.review── 書類1枚分の集計と、確認が要る項目の作業リストflagged。{ path, reasons }の配列で、pathはcellsのキーと同じ文法なのでそのまま引けます。件数はflagged.lengthです。data.normalized── スカラー型やpattern/enumを宣言したときだけ付く層。valuesと同じ形のツリーで、葉だけがその型に解釈された値です。data.image── 実際に読み取ったページのwidth/height(px)。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 がページ上で実際に検出したシンボルと1文字ずつ照合します ── だから矩形は、その文字が本当に見つかったピクセルに着地します。照合が通ったかは evidence.text_match、どれだけ一致したかは evidence.match_ratio に残ります。セルの verified はその上の判定で、review の鏡です ── 検討理由が1つでも立てば false、何も立たずに照合が走っていれば true、行のユニオンのように照合する相手が無ければ null。したがって verified: false と text_match: true は矛盾ではなく、「文字は合っているが、宣言した規則が引っかかった」という正常な組み合わせです。ただし2つのエンジンが同じ誤読で一致してしまえば、その値は通り得ます ── 出どころの検証と、業務側の規則(required・pattern・enum・near)は互いを補う2つの層で、本番ではどちらも回します。詳しくはバウンディングボックスで OCR を監査可能にする仕組みを参照してください。
宛先と発行元が、同じページに並んでいる
日本の請求書・納品書でいちばん厄介な誤りは、文字を読み違えたものではありません。文字は完璧に読めているのに、取ってきた場所が違うものです。1枚の紙に会社名が2つ ── 宛先と発行元 ── 印字されていて、反対側を拾っても文字照合は一致するので 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 は、宣言した語がページのどこにも印字されていないときは判定を保留し、その事実を review.notes に issue: "near_unresolved" として知らせます ── 「御中」を印字しない事務用フォームを罰しないためです。取り違えが起きるのはむしろそういう書式なので、そこに届くのは 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": "明細1行につき1要素",
"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 はモデルがページから読んだ文字列で、バイト単位の複製ではありません。 文字照合は全角・括弧・空白を畳んでから比べるので、(税抜) が (税抜) になる程度の書き直しは通ります ── 完全一致で突合したいときは、その座標で OCR が読んだ文字である cells[path].evidence.printed_text を使ってください。数値や日付として扱いたいときは 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)のような文字がそのまま入るため、cp932 / Shift_JIS に変換せず UTF-8 のまま扱ってください。明細が「1セルに潰れる」のを防ぐ鍵は type: "array" + children で、これにより1明細=1行に展開されます。
文字をクリックして、元の箇所へジャンプ
シートに蓄積した後は、値をクリックすると元画像の該当箇所が点灯します。これがバッチのスポットチェックで一番速い方法です ── 書類全体を見渡す代わりに、視線がそのまま該当箇所へ飛びます。全項目を見比べる必要はありません。data.review.flagged に上がった項目 ── 文字が合わなかった、宣言した規則を破った、必須なのに返らなかった ── だけを開けば、確認すべき位置は cells[path] の座標がそのまま指してくれます。
非同期で大量に ── バッチアップロード・ジョブ・Webhook
POST /ocr/fields は同期で、リクエスト/レスポンスのループに置く1枚処理に最適です。請求書・納品書のフォルダをまとめて処理するなら、シートに対して POST /upload(multipart の files を繰り返し)で投げます。既定では即座にジョブ配列が返ります。
{ "path": "...", "jobs": [ { "uniqueKey": "...", "jobId": "...", "status": "pending" } ] }結果の受け取り方は2通り。GET /jobs/{jobId} をポーリングするか、Webhook を登録します。Webhook はスペースごとに1 URL、すべてのイベントが X-Spaceocr-Signature ヘッダで HMAC-SHA256 署名されます。注目すべきイベントは upload.received・item.created・ocr.completed(data.result に抽出結果)・ocr.failed。ペイロードを信頼する前に、必ず署名を検証してください。
冪等性・リクエスト追跡・レート制限
本番パイプラインを安全にリトライ可能にするための、いくつかのヘッダがあります。
| ヘッダ | 役割 |
|---|---|
Idempotency-Key | /ocr/fields・/create・/upload で、同じキーの再送は 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・マネーフォワード・弥生・kintone への連携は公式 API 連携ではなく、書き出した CSV の取り込みで行う前提です。また、インボイス制度や電子帳簿保存法への対応可否は、各社の運用・要件にあわせてご確認ください(本サービスが法要件の充足を保証するものではありません)。
料金
POST /ocr/fields は 1コール ¥10(税込)、POST /upload は ¥10 × N 枚です。失敗時は課金なし ── 画像が読めなかった 400(invalid_image)や同期処理の上限を超えた 504 はそもそも課金されず、502 エンジンエラーと ocr.failed イベントは自動で返金されます。読み取り専用エンドポイント(GET /space・/view・/amount・/health)は無料。無料枠はカード登録不要で 毎月 100 クレジット、Pro は ¥8,980/月(税込) です。プランの一覧は料金ページにあります。
請求書・納品書を 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 を叩く取り出したい項目を fields[] に FieldSpec({name, type, description, required, label, pattern, near, not_near, children})で宣言します。明細は行数を数えず type:"array" + children で1行分の形だけ宣言し、返る行数はページに任せます。どんな項目が載るか分からない書式は autoFields: true でスキーマを提案させることもできます。
- レスポンスを検証する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・マネーフォワード・弥生などの CSV 取り込みに渡します。蓄積後は GET /view で再 OCR・無課金のままクエリできます。