청구서 OCR API·납품서 OCR로 CSV 만들기 ── 청구서 데이터 추출 API 구현 가이드
청구서·납품서를 수기 입력과 Excel 깨짐에서 벗어나게 해 주는 개발자용 가이드. POST /ocr/fields 에 이미지를 보내면 거래처·날짜·합계·명세가 구조화 데이터로 돌아오고, 각 값마다 원본 이미지의 좌표(bbox)와 match_ratio가 함께 붙습니다. curl·Python 코드, CSV 출력, Webhook, 요금까지 한 번에.
청구서나 납품서를 아직도 손으로 Excel에 일일이 입력하고 계신가요. 날짜, 거래처, 공급가액·합계, 그리고 명세 한 줄 한 줄 ── 월말이 되면 산더미처럼 쌓인 종이를 노려보며 숫자를 한 셀씩 옮겨 적습니다. 중간에 한 자리가 어긋나서 합계가 안 맞으면, 또 처음부터 대조해야 하죠. 그 시간을, 없애고 싶습니다.
스캔한 PDF를 복사하려고 하면 문자가 선택되지 않습니다. OCR을 돌리면 명세가 전부 한 셀에 뭉개져 줄바꿈도 열도 사라집니다. CSV를 Excel에서 열면 글자가 깨져서 품명을 읽을 수 없습니다. 그저 회계 소프트웨어에 가져오고 싶을 뿐인데, 그 직전 단계에서 매번 발목을 잡힙니다 ── 서류를 다루는 현장이라면 누구나 겪는 일입니다.
이 글은 그 작업을 API 한 번으로 대체하기 위한 개발자용 가이드입니다. POST /ocr/fields 에 청구서·납품서 이미지를 보내면 거래처·날짜·합계 같은 항목과 명세의 각 행이 타입이 붙은 구조화 데이터로 돌아옵니다. 게다가 돌아오는 모든 값에 원본 이미지의 어디에서 읽어 냈는지를 나타내는 좌표(bbox)가 붙기 때문에, 추출 결과를 그대로 믿지 않고 원본과 대조해 검증할 수 있습니다. curl과 Python 코드와 함께, 최단 경로부터 실제 운영까지 차근차근 살펴보겠습니다.
일단 직접 만져 보기 ── 업로드 불필요, 10초면 체험
코드를 쓰기 전에, 실제 출력을 먼저 보세요. 아래는 진짜 영수증을 해석한 결과입니다. 항목에 커서를 올리면 그 값이 이미지의 어디에서 읽혔는지 하이라이트되고, 각 항목의 **매치율(match_ratio)**도 확인할 수 있습니다. 청구서·납품서도 동작은 완전히 똑같습니다 ── 추출된 값 하나하나가 읽어 낸 픽셀에 연결됩니다.

Every value carries a verified on-page location — bbox + 4-point vertices + 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 하나뿐입니다 ── /v1 같은 경로 버저닝은 없습니다. 각 요청은 spocr_ 로 시작하는 키를 사용한 HTTP Bearer 토큰으로 인증합니다.
Authorization: Bearer spocr_xxxxxxxxxxxxxxxx
헤더가 빠졌거나 잘못됐으면 401, 등록되지 않은 키면 403이 돌아옵니다. 모든 응답에 X-Request-Id(형식 req_xxx) 헤더가 붙으니, 지원 문의용으로 로그에 남겨 두면 안심입니다. 클라이언트를 자동 생성하고 싶다면 GET /openapi.json 에 OpenAPI 3.1 사양이 공개되어 있습니다.
최단 경로 ── 빌트인 청구서·납품서 템플릿
가장 빠른 방법은 templateId 에 내장 템플릿을 지정하는 것입니다. 청구서면 templateId: "invoice", 납품서면 templateId: "delivery". '청구서란 어떤 항목을 가지는가'를 엔진 쪽이 알고 있으므로, 항목을 하나씩 직접 정의할 필요가 없습니다. 이미지는 URL로도 순수 base64로도 넘길 수 있습니다(imageType 은 http(s):// 프리픽스 유무로 자동 판정됩니다).
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",
"templateId": "delivery"
}'파라미터는 카멜케이스가 정식 이름입니다. imageType / templateId / autoFields 를 사용하세요. 기존 스네이크케이스(image_type / template_id / auto_fields)도 동작하지만 비권장입니다. 새 코드에서는 카멜케이스를 우선하세요. 참고로 청구서면 templateId: "invoice", 납품서면 templateId: "delivery" 입니다.
응답의 형태 ── 값마다 '출처'가 붙는다
성공하면 { status: "success", data: { ... } } 가 돌아옵니다. 추출된 각 값은 저마다 출처 정보를 가지며, field_bboxes 맵에 항목별 좌표가 모여 있습니다.
bbox──{ xmin, ymin, xmax, ymax }의 축 정렬 사각형. 0~1000으로 정규화된 그리드 위의 정수 좌표입니다(0,0이 좌상단, 1000,1000이 우하단). 이미지의 픽셀 크기에 의존하지 않습니다. 픽셀 변환은pixel_x = bbox_x / 1000 × image_width.vertices── 좌상 → 우상 → 우하 → 좌하 순으로 나열된 4점{x, y}. 서류의 기울기를 따라가는 회전 대응(oriented) 사각형이므로, 비스듬히 찍은 스마트폰 사진도 깔끔하게 감쌀 수 있습니다.match_ratio── 그 값의 문자 중에서 실제로 페이지 위에서 찾아낸 비율(0~1). 0.85 이상이면 확신 매치로 취급되며,1.0은 모든 문자가 페이지 위에서 발견됐다는 뜻입니다.bbox_source── 좌표를 어떻게 도출했는지 나타내는 라벨.vision_symbol_match(문자 매치가 0.85 이상으로 안착한 일반 경로 ── 실제match_ratio를 동반),token_id/token_id_hybrid(LLM이 돌려준 워드 토큰 힌트로 Vision의 단어 토큰을 끌어온 경로),low_confidence(문자 매치가 0.85 미만 ── 확인 필요),shared_value(병합된 셀에서의 전파).
{
"status": "success",
"data": {
"total": "2,045",
"field_bboxes": {
"total": {
"bbox": { "xmin": 595, "ymin": 974, "xmax": 781, "ymax": 1000 },
"vertices": [
{ "x": 594, "y": 975 }, { "x": 781, "y": 972 },
{ "x": 781, "y": 998 }, { "x": 595, "y": 1000 }
],
"match_ratio": 0.93,
"bbox_source": "vision_symbol_match"
}
}
}
}좌표는 AI의 말을 그대로 믿지 않습니다. 언어 모델이 돌려주는 것은 각 값의 텍스트와, 사용한 워드 토큰의 **힌트(wid)**뿐이며, 좌표 자체는 돌려주지 않습니다. 엔진은 먼저 그 텍스트를 Vision OCR이 페이지 위에서 실제로 검출한 심볼과 한 글자씩 대조합니다 ── 그래서 사각형은 그 문자가 정말로 발견된 픽셀에 안착하고, 각 값에는 '얼마나 일치했는지'를 나타내는 match_ratio 가 붙습니다. LLM이 워드 토큰 힌트를 돌려줬을 때는 그 토큰의 좌표로 일부 항목을 덮어쓰기도 하지만, 이 힌트는 노이즈를 포함할 수 있어(반복되는 행에서 옆 행과 뒤바뀌는, 이른바 stochastic한 흔들림) 맹신하지 않고 열·행의 정합성으로 검증·보정한 뒤에 채택합니다. 핵심은 'AI가 틀리지 않는다'가 아니라, 어떤 값이든 페이지 쪽에 다시 대조되고, 얼마나 일치했는지의 점수가 남는다는 점입니다. 자세한 내용은 바운딩 박스로 OCR을 감사 가능하게 만드는 구조를 참고하세요.
템플릿으로 부족할 때 ── 커스텀 항목
실무 청구서에는 범용 템플릿이 이름 붙이지 않은 항목이 있습니다 ── 발주 번호, 결제 조건 코드, 프로젝트 태그 등. 그럴 때는 templateId 대신(또는 함께) FieldSpec의 배열 fields 를 넘깁니다. 각 FieldSpec은 { name, type, description?, children? }. fields 와 templateId 를 둘 다 보낸 경우에는 fields 가 우선합니다.
description 이 모델을 유도하는 지점입니다 ── 무엇을 어떻게 잡아낼지를 자연어 지시문으로 쓸 수 있습니다. 그리고 type: "array" 와 children 의 조합이 반복되는 명세 행을 끌어내는 방법입니다. 자식 스키마를 하나만 정의하면, 행이 몇 개든 돌아옵니다.
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": "vendor", "type": "string",
"description": "納品元(取引先)の会社名"},
{"name": "delivery_no", "type": "string",
"description": "納品書番号。原文のまま"},
{"name": "delivery_date", "type": "string",
"description": "納品日。和暦・西暦は原文のまま保持"},
{"name": "total", "type": "string",
"description": "合計金額。カンマ区切りは保持"},
{"name": "items", "type": "array",
"description": "明細1行につき1要素",
"children": [
{"name": "name", "type": "string", "description": "品名"},
{"name": "qty", "type": "number", "description": "数量"},
{"name": "unit_price", "type": "number", "description": "単価"},
]},
],
},
timeout=60,
)
data = resp.json()["data"]
# 明細を CSV へ。Excel と CJK のため UTF-8 BOM で書き出す
with open("delivery.csv", "w", encoding="utf-8-sig", newline="") as out:
w = csv.writer(out)
w.writerow(["品名", "数量", "単価"])
for row in data.get("items", []):
w.writerow([row["name"], row["qty"], row["unit_price"]])값은 원문 그대로(verbatim) 보존됩니다. 합계 7,855 는 문자열 "7,855" 로 돌아오고, 콤마 구분·소수점·전각 문자도 그대로입니다. description 으로 명시적으로 부탁했을 때만 정규화합니다. UI에 보이는 ¥ 는 장식이며, 값의 일부가 아닙니다. CSV 글자 깨짐 대책으로, Excel에서 여는 CSV는 UTF-8 BOM(utf-8-sig)으로 내보내는 게 요령입니다 ── 위 코드도 그렇게 합니다. 참고로 명세가 '한 셀에 뭉개지는' 것을 막는 열쇠가 type: "array" + children 이며, 이로써 명세 1건=1행으로 전개됩니다.
문자를 클릭해, 원본 위치로 점프
시트에 쌓은 뒤에는 값을 클릭하면 원본 이미지의 해당 위치가 점등합니다. 이게 배치 처리의 스폿 체크에서 가장 빠른 방법입니다 ── 서류 전체를 훑어보는 대신, 시선이 그대로 해당 위치로 날아갑니다. match_ratio 가 낮은 항목만 우선해서 확인하는 식의 운영도 가능합니다.
비동기로 대량 처리 ── 배치 업로드·잡·Webhook
POST /ocr/fields 는 동기 방식이라, 요청/응답 루프에 두는 1장 처리에 가장 적합합니다. 청구서·납품서 폴더를 한꺼번에 처리하려면, 시트에 대해 POST /upload(multipart의 files 를 반복)로 보냅니다. 기본적으로 즉시 잡 배열이 돌아옵니다.
{ "path": "...", "jobs": [ { "uniqueKey": "...", "jobId": "...", "status": "pending" } ] }
결과를 받는 방법은 두 가지입니다. GET /jobs/{jobId} 를 폴링하거나, Webhook을 등록합니다. Webhook은 스페이스마다 1개 URL이고, 모든 이벤트가 X-Spaceocr-Signature 헤더로 HMAC-SHA256 서명됩니다. 주목할 이벤트는 upload.received·item.created·ocr.completed(data.result 에 추출 결과)·ocr.failed. 페이로드를 신뢰하기 전에 반드시 서명을 검증하세요.
멱등성·요청 추적·레이트 리밋
프로덕션 파이프라인을 안전하게 재시도 가능하게 만들기 위한 헤더가 몇 가지 있습니다.
| 헤더 | 역할 |
|---|---|
Idempotency-Key | /upload 와 /create 에서 같은 키의 재전송은 24시간 캐시 응답을 재생(X-Idempotent-Replay: true) ── 이중 과금 없이 안전하게 재시도. |
X-Request-Id | 모든 응답에 붙음(req_xxx). 지원 문의용으로 로그에. |
레이트 리밋은 키당 60 요청/분, uid당 600 요청/분(고정 60초 윈도우)입니다. 초과하면 429 와 error.code: "rate_limited" 가 돌아옵니다. 대기 초수는 JSON 바디의 details.retryAfterSec 에 들어갑니다 ── Retry-After HTTP 헤더가 아닙니다. 백오프는 바디의 값을 보고 수행하세요.
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded",
"requestId": "req_8fa2c1"
},
"details": { "retryAfterSec": 12 }
}추출에서, 쿼리할 수 있는 시트로
청구서를 시트에 추출했다면, 다시 읽어 오기 위해 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 등으로 렌더링한 뒤 보내세요(웹 앱에 드롭하는 경우에는 페이지 이미지화를 앱이 자동으로 처리하므로 PDF를 그대로 던질 수 있습니다). freee·마네 포워드(マネーフォワード)·야요이(弥生)·kintone 연계는 공식 API 연동이 아니라, 내보낸 CSV 가져오기로 한다는 전제입니다. 또한 인보이스 제도(적격청구서)나 전자장부보존법 대응 가능 여부는 각 사의 운영·요건에 맞춰 확인하세요(본 서비스가 법적 요건 충족을 보장하지는 않습니다).
요금
POST /ocr/fields 는 1콜 ¥10, POST /upload 는 ¥10 × N장입니다. 실패 시에는 무과금 ── OCR이 결과를 돌려주지 않으면 환불되고, 502 엔진 에러나 ocr.failed 이벤트는 자동으로 환불됩니다. 읽기 전용 엔드포인트(GET /space·/view·/amount·/health)는 무료. 무료 플랜은 신용카드 불필요로 월 100장, Pro는 $39/월, Business는 문의(견적)입니다.
청구서·납품서를 API로 추출하는 절차
- API 키 준비하기로그인해서 spocr_ 로 시작하는 API 키를 발급하고, 각 요청에 Authorization: Bearer spocr_... 를 붙입니다. 베이스 URL은 https://api.space-ocr.com 입니다.
- 이미지 준비하기(PDF는 페이지를 이미지화)청구서·납품서를 JPEG/PNG 등의 래스터 이미지로 준비합니다. API를 직접 호출하는 경우, PDF는 각 페이지를 PNG로 렌더링한 뒤 보냅니다(웹 앱에 드롭하는 경우에는 앱이 자동으로 이미지화합니다). 이미지는 URL 또는 순수 base64로 넘기고, imageType을 url / base64 로 지정합니다.
- POST /ocr/fields 호출하기청구서면 templateId: "invoice", 납품서면 templateId: "delivery" 를 지정합니다. 템플릿으로 부족한 항목은 fields[](FieldSpec {name,type,description,children})로 정의하고, 명세는 type:"array" + children 으로 한 줄씩 전개합니다.
- 응답 검증하기돌아온 각 값의 bbox·vertices·match_ratio·bbox_source를 확인합니다. match_ratio가 0.85 미만(low_confidence)인 항목은 원본과 대조해 확인합니다.
- CSV로 만들어 회계 소프트웨어로추출 결과를 UTF-8 BOM이 붙은 CSV로 내보내고(명세는 배열 행으로 전개), freee·마네 포워드(マネーフォワード)·야요이(弥生) 등의 CSV 가져오기에 넘깁니다. 쌓인 뒤에는 GET /view 로 재 OCR·무과금 상태로 쿼리할 수 있습니다.