space ocr
가이드아티클요금문서

청구서 OCR API·납품서 OCR로 CSV 만들기 ── 청구서 데이터 추출 API 구현 가이드

청구서·납품서를 수기 입력과 Excel 깨짐에서 벗어나게 해 주는 개발자용 가이드. 뽑을 항목을 선언해 POST /ocr/fields 에 이미지를 보내면 거래처·날짜·합계·명세가 구조화 데이터로 돌아오고, 값마다 원본 이미지의 좌표(box·quad)와 확인이 필요한 항목을 모은 review 목록이 함께 붙습니다. curl·Python 코드, CSV 출력, Webhook, 요금까지 한 번에.

청구서나 납품서를 아직도 손으로 Excel에 일일이 입력하고 계신가요. 날짜, 거래처, 공급가액·합계, 그리고 명세 한 줄 한 줄 ── 월말이 되면 산더미처럼 쌓인 종이를 노려보며 숫자를 한 셀씩 옮겨 적습니다. 중간에 한 자리가 어긋나서 합계가 안 맞으면, 또 처음부터 대조해야 하죠. 그 시간을, 없애고 싶습니다.

스캔한 PDF를 복사하려고 하면 문자가 선택되지 않습니다. OCR을 돌리면 명세가 전부 한 셀에 뭉개져 줄바꿈도 열도 사라집니다. CSV를 Excel에서 열면 글자가 깨져서 품명을 읽을 수 없습니다. 그저 회계 소프트웨어에 가져오고 싶을 뿐인데, 그 직전 단계에서 매번 발목을 잡힙니다 ── 서류를 다루는 현장이라면 누구나 겪는 일입니다.

이 글은 그 작업을 API 한 번으로 대체하기 위한 개발자용 가이드입니다. POST /ocr/fields 에 청구서·납품서 이미지를 보내면 거래처·날짜·합계 같은 항목과 명세의 각 행이 타입이 붙은 구조화 데이터로 돌아옵니다. 게다가 돌아오는 모든 값에 원본 이미지의 어디에서 읽어 냈는지를 나타내는 좌표(box·quad)가 붙기 때문에, 추출 결과를 그대로 믿지 않고 원본과 대조해 검증할 수 있습니다. curl과 Python 코드와 함께, 최단 경로부터 실제 운영까지 차근차근 살펴보겠습니다.

일단 직접 만져 보기 ── 업로드 불필요, 10초면 체험

코드를 쓰기 전에, 실제 출력을 먼저 보세요. 아래는 진짜 영수증을 해석한 결과입니다. 항목에 커서를 올리면 그 값이 이미지의 어디에서 읽혔는지 하이라이트됩니다. 청구서·납품서도 동작은 완전히 똑같습니다 ── 추출된 값 하나하나가 읽어 낸 픽셀에 연결되고, 대조가 맞지 않은 항목은 data.review.flagged 에 확인 대상으로 올라옵니다.

Delivery slip with extracted-field bounding boxes
Verified fields
Delivery slip

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장을 보내 항목이 채워지는 모습부터 보시죠.

청구서를 1장 드롭하면 타입이 붙은 항목이 자동으로 채워집니다 ── API가 돌려주는 것과 똑같은 데이터를 UI에서 그대로.

인증과 베이스 URL

공개 API의 베이스는 https://api.space-ocr.com 하나뿐입니다 ── /v1 같은 경로 버저닝은 없습니다. 각 요청은 spocr_ 로 시작하는 키를 사용한 HTTP Bearer 토큰으로 인증합니다.

1
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 는 선언하든 안 하든 같은 읽기가 돌아옵니다. 선언이 만드는 것은 두 가지입니다 ── 같은 읽기를 그 타입으로 해석한 data.normalized 층, 그리고 규칙을 어긴 항목에 서는 검토 사유(type_mismatch·out_of_range·pattern_mismatch·missing). 명세처럼 반복되는 행은 행 수를 세지 말고 type: "array" 와 children 으로 한 행의 형태만 선언합니다 ── 몇 행이 돌아올지는 페이지가 정하고, 자식의 좌표는 행 단위로 풀리기 때문에 모든 행에 같은 '수량'·'금액' 열 제목이 반복돼도 서로 헷갈리지 않습니다.

POST /ocr/fields ── 납품서 항목을 선언해 호출
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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": "합계 금액" }
    ]
  }'
Why it matters

바디 파라미터는 카멜케이스가 정식 이름입니다. 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 ── path를 키로 쓰는 flat 좌표·검증 맵. 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(px). EXIF 방향을 반영한 뒤의 값이므로, 좌표를 이미지 위에 겹칠 때는 이쪽을 기준으로 삼습니다.

evidence 에 실리는 text_match(문자 대조가 통과했는지)와 match_ratio(그 값의 문자 중 페이지에서 찾아낸 비율)는 판정을 뒷받침하는 증거입니다. 직접 임계값을 정해 전 항목을 훑는 것보다, review.flagged 를 그대로 작업 목록으로 받는 편이 확실합니다.

POST /ocr/fields → 응답(발췌)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
{
  "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 }
  }
}
✓ Verified

좌표는 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 는 선언한 어휘가 페이지 어디에도 인쇄돼 있지 않으면 판정을 보류하고, 그 사실을 review.notes 에 issue: "near_unresolved" 로 알립니다 ── '御中' 를 찍지 않는 사무용 폼을 벌하지 않기 위해서입니다. 당사자가 뒤바뀌는 것은 오히려 그런 서식이라, 거기에 닿는 것은 not_near 쪽입니다. 어휘가 인쇄물의 어디에 붙는 것을 인정할지는 match 로 지정하며, 기본값 boundary 외에 suffix(御中·様)·prefix(〒·TEL)·standalone·anywhere 를 고를 수 있습니다 ── 공사명 '中野様邸増築工事' 안의 '様' 가 수신처 판정의 증인이 되지 않도록 하는 지정입니다. 두 선언 모두 모델에는 전달되지 않으므로 추출되는 값은 달라지지 않습니다. 옳은 쪽을 고르게 만드는 장치가 아니라, 틀린 자리에서 읽은 값을 보이게 만드는 장치로 이해하시면 됩니다.

항목 스키마를 선언해 명세를 CSV로(납품서)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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")])
Why it matters

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 그대로 다뤄 주세요. 명세가 '한 셀에 뭉개지는' 것을 막는 열쇠는 type: "array" + children 이며, 이로써 명세 1건=1행으로 전개됩니다.

문자를 클릭해, 원본 위치로 점프

시트에 쌓은 뒤에는 값을 클릭하면 원본 이미지의 해당 위치가 환하게 켜집니다. 이 방식이 배치 처리의 스폿 체크에서 가장 빠릅니다 ── 서류 전체를 훑어보는 대신, 시선이 그대로 해당 위치로 날아갑니다. 전 항목을 비교할 필요는 없습니다. data.review.flagged 에 오른 항목 ── 글자가 맞지 않았거나, 선언한 규칙을 어겼거나, 필수인데 돌아오지 않은 값 ── 만 열면 확인할 자리는 cells[path] 의 좌표가 그대로 가리켜 줍니다.

추출된 청구서를 가로질러 검색하고, 적중한 셀 ── 과 그 원본 이미지의 해당 위치로 단번에 점프.

비동기로 대량 처리 ── 배치 업로드·잡·Webhook

POST /ocr/fields 는 동기 방식이라, 요청/응답 루프에 두는 1장 처리에 가장 적합합니다. 청구서·납품서 폴더를 한꺼번에 처리하려면, 시트에 대해 POST /upload(multipart의 files 를 반복)로 보냅니다. 기본적으로 즉시 잡 배열이 돌아옵니다.

1
{ "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/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 헤더(초)에 들어갑니다.

429 응답 바디
1
2
3
4
5
6
7
{
  "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 문서에 정리되어 있습니다.

Note

PDF는 페이지를 이미지로 변환한 뒤 보냅니다. OCR 엔진이 직접 해석하는 것은 래스터 이미지(JPEG·PNG·GIF·BMP·TIFF·WebP)입니다. API를 직접 호출하는 경우에는 PDF의 각 페이지를 PNG 등으로 렌더링한 뒤 보내세요(웹 앱에 드롭하는 경우에는 페이지 이미지화를 앱이 자동으로 처리하므로 PDF를 그대로 던질 수 있습니다). freee·마네 포워드(マネーフォワード)·야요이(弥生)·kintone 연계는 공식 API 연동이 아니라, 내보낸 CSV 가져오기로 한다는 전제입니다. 또한 인보이스 제도(적격청구서)나 전자장부보존법 대응 가능 여부는 각 사의 운영·요건에 맞춰 확인하세요(본 서비스가 법적 요건 충족을 보장하지는 않습니다).

요금

POST /ocr/fields 는 1콜 ₩100(부가세 포함), POST /upload 는 ₩100 × N장입니다. 실패는 과금하지 않음 ── 이미지를 읽지 못한 400(invalid_image)이나 동기 처리 상한을 넘은 504 는 애초에 과금되지 않고, 502 엔진 에러와 ocr.failed 이벤트는 자동으로 환불됩니다. 읽기 전용 엔드포인트(GET /space·/view·/amount·/health)는 무료입니다. 무료 플랜은 신용카드 없이 월 100크레딧, Pro는 ₩69,800/월(부가세 포함) 입니다. 플랜 목록은 요금 페이지에 있습니다.

청구서·납품서를 API로 추출하는 절차

  1. API 키 준비하기
    로그인해서 spocr_ 로 시작하는 API 키를 발급하고, 각 요청에 Authorization: Bearer spocr_... 를 붙입니다. 베이스 URL은 https://api.space-ocr.com 입니다.
  2. 이미지 준비하기(PDF는 페이지를 이미지화)
    청구서·납품서를 JPEG/PNG 등의 래스터 이미지로 준비합니다. API를 직접 호출하는 경우, PDF는 각 페이지를 PNG로 렌더링한 뒤 보냅니다(웹 앱에 드롭하는 경우에는 앱이 자동으로 이미지화합니다). 이미지는 URL 또는 순수 base64로 넘기고, imageType을 url / base64 로 지정합니다.
  3. POST /ocr/fields 호출하기
    뽑고 싶은 항목을 fields[] 에 FieldSpec({name, type, description, required, label, pattern, near, not_near, children})으로 선언합니다. 명세는 행 수를 세지 말고 type:"array" + children 으로 한 행의 형태만 선언하고, 몇 행이 돌아올지는 페이지에 맡깁니다. 어떤 항목이 실릴지 모르는 서식은 autoFields: true 로 스키마를 제안받을 수도 있습니다.
  4. 응답 검증하기
    data.review.flagged 에 놓인 {path, reasons} 를 작업 목록으로 열고, 그 path 로 data.cells[path] 를 조회해 box·quad 좌표와 evidence(text_match·match_ratio)를 확인합니다. 타입을 선언한 항목은 data.normalized 에 해석된 값이 놓이고, 해석하지 못한 이유는 cells[path].normalized.error 에 들어갑니다.
  5. CSV로 만들어 회계 소프트웨어로
    추출 결과를 UTF-8 BOM이 붙은 CSV로 내보내고(명세는 배열 행으로 전개), freee·마네 포워드(マネーフォワード)·야요이(弥生) 등의 CSV 가져오기에 넘깁니다. 쌓인 뒤에는 GET /view 로 재 OCR·무과금 상태로 쿼리할 수 있습니다.
청구서·납품서 OCR API는 한국어에 대응하나요?
네. 언어는 완전 자동이라 힌트를 지정할 필요가 없습니다. 한국어·일본어·영어·중국어를 하나의 엔진으로 처리하고, 전각/반각이나 괄호 같은 표기 차이는 문자 대조 때 접고 비교합니다. values 에 돌아오는 것은 모델이 페이지에서 읽은 문자열이라, 날짜도 품명도 인쇄된 대로의 읽기입니다. 다만 바이트 단위 복사본은 아니므로, 정확 일치로 대조하실 거면 cells[path].evidence.printed_text(그 좌표에서 OCR 이 읽은 글자)를 쓰세요. 날짜나 숫자로 다루고 싶으면 type 을 선언하고, 결정론적으로 해석된 값을 data.normalized 층에서 받으시면 됩니다.
PDF 청구서·납품서에도 대응하나요?
대응합니다. 다만 OCR 엔진이 직접 해석하는 것은 래스터 이미지(JPEG·PNG·GIF·BMP·TIFF·WebP)입니다. API를 직접 호출하는 경우에는 PDF의 각 페이지를 PNG 등으로 렌더링한 뒤 보내세요. 웹 앱에 PDF를 드롭하는 경우에는 페이지 이미지화를 앱이 자동으로 처리하므로, PDF를 그대로 던져 OCR할 수 있습니다.
추출한 데이터를 freee나 마네 포워드(マネーフォワード), 야요이(弥生)에 가져올 수 있나요?
CSV를 통해 가져올 수 있습니다. 시트를 CSV로 내보낼 수 있고(Excel과 CJK를 위해 UTF-8 BOM 포함), 명세는 배열 행으로 전개되므로 각 회계 소프트웨어의 CSV 가져오기 기능에 넘길 수 있습니다. 이들은 공식 API 연동이 아니라, 내보낸 CSV 가져오기로 운영한다는 전제입니다.
추출 정확도는 어떻게 담보되나요? 결과를 믿어도 되나요?
값마다 cells[path] 가 붙고, box(0~1000으로 정규화한 축 정렬 사각형)와 quad(기울기를 따라가는 4점)가 원본 이미지의 어디에서 읽었는지를 가리킵니다. 좌표는 AI가 만드는 게 아니라, 추출된 텍스트를 페이지 위에서 OCR이 실제로 검출한 심볼과 한 글자씩 대조해 도출합니다. 대조가 통과했는지는 evidence.text_match, 얼마나 일치했는지는 evidence.match_ratio 에 남고, 셀의 verified 는 그 위의 판정입니다. 확인해야 할 항목은 data.review.flagged 에 {path, reasons} 작업 목록으로 돌아오므로, 직접 임계값을 정해 전 항목을 훑을 필요가 없습니다. 다만 두 엔진이 같은 오독에 합의하면 통과할 수 있으니, required·pattern·enum·near 같은 업무 쪽 규칙과 함께 운영하세요.
개인정보나 데이터 취급, 실패 시 과금은 어떻게 되나요?
실패는 과금하지 않습니다. 이미지를 읽지 못한 400 이나 동기 처리 상한을 넘은 504 는 애초에 과금되지 않고, 502 엔진 에러나 ocr.failed 이벤트는 자동으로 환불됩니다. Webhook은 스페이스마다 1개 URL이며, 모든 이벤트가 X-Spaceocr-Signature 헤더로 HMAC-SHA256 서명되므로 수신 측에서 서명을 검증한 뒤 처리할 수 있습니다. 멱등 키(Idempotency-Key)로 재시도 시의 이중 과금도 막을 수 있습니다.

첫 청구서를, 한 번의 콜로 추출

무료 플랜 ── 월 100장, 신용카드 불필요. 모든 값이 원본 이미지의 어디에서 읽어 냈는지 좌표와 함께 돌아옵니다.

관련