소개
REST 기반, JSON, CORS 다 돼요. 가변 길이 배치나 비동기 처리는 Jobs / Webhooks 섹션을 보세요.
5분 퀵스타트
① Developer → API Keys 에서 키를 발급하세요 (카드 불필요).
② 오른쪽 curl 을 그대로 실행하세요 — 샘플 이미지가 실제로 호스팅돼 있어서 키만 바꾸면 바로 돌아가요.
③ 응답의 data.values 값과 data.cells 의 box / quad / verified, 그리고 data.review.flagged(검토 목록)를 확인하세요. 필드에 타입이나 제약(number / date / pattern / enum / near)을 선언하면 해석된 값이 data.normalized 에, 위반이 review 에 실려요 — 선언할 수 있는 목록은 POST /ocr/fields 의 fields 를 보세요.
④ 코드를 쓰기 전에 먼저 보고 싶다면 — 마이스페이스 콘솔이 그대로 플레이그라운드예요. 시트에 파일을 올리면 API 와 똑같은 결과가 나오고, 셀을 누르면 원본 좌표까지 확인할 수 있어요. API 로 올린 문서도 같은 시트에 나타나서, 자동 처리와 눈으로 하는 검수를 한자리에서 다룰 수 있어요.
curl -X POST https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://space-ocr.com/samples/two-receipts.jpg",
"imageType": "url",
"fields": [{ "name": "store_name" }, { "name": "total" }]
}'인증
키 형식은 spocr_ 로 시작해요. 혹시 노출되면 바로 폐기해주세요.
curl https://api.space-ocr.com/amount \
-H "Authorization: Bearer YOUR_API_KEY"Base URL
# Production
https://api.space-ocr.com
# OpenAPI spec
https://api.space-ocr.com/openapi.jsonRate limits
응답엔 항상 X-Request-Id (req_xxx) 와 X-RateLimit-Remaining (이번 분에 남은 호출 수) 가 붙어요. 문의하실 때 X-Request-Id 를 같이 주시면 좋아요.
/ocr/fields・/create・/upload 에서는 Idempotency-Key 헤더를 쓸 수 있어요. 같은 키로 다시 보내면 24h 동안 캐시된 응답이 그대로 와요. 이땐 X-Idempotent-Replay: true 헤더가 붙어요.
이미지 크기와 응답 시간
JSON 바디 상한은 28MB 예요. base64 는 파일의 약 1.33배가 되니, 원본 이미지로는 20MB 쯤까지 한 요청에 담을 수 있어요. 넘으면 413 을 돌려드리고 details.limitBytes 에 허용 크기, details.receivedBytes 에 받은 크기가 담겨요.
다만 32MiB 를 넘는 요청은 저희 코드에 닿기 전에 Google Cloud 쪽에서 끊깁니다. 이때 응답은 JSON 이 아니라 text/html (Google Frontend 의 413 페이지) 이라, 응답을 무조건 JSON 으로 파싱하는 구현은 예외로 떨어져요. 위의 28MB 를 지키시면 이 경로까지 가지 않아요.
큰 사진은 서버 내부 인코딩 결과에 따라 긴 변을 4000px 로 줄여서 읽습니다 (종횡비는 유지). 실제로 읽은 크기는 data.image 의 width / height 에 담기니, 보내신 이미지와 다를 수 있어요. 좌표는 0–1000 정규화 값이라 줄어들어도 의미는 그대로예요. 클라이언트에서 줄여 보내신다면 긴 변 4000px 이 기준이 됩니다.
방향도 마찬가지예요. EXIF orientation 은 읽기 전에 픽셀에 반영되기 때문에, 옆으로 눕혀 찍은 사진은 정립한 페이지로 읽히고 값도 좌표도 그 방향으로 돌아와요. data.image 의 width 와 height 가 보내신 파일과 뒤바뀌는 게 이때예요 (4000×3000 으로 보내고 3000×4000). 테두리를 그리는 구현은 송신 이미지 크기가 아니라 data.image 를 기준으로 삼아 주세요.
상한을 넘는 이미지는 imageType: "url" 로 URL 을 넘기거나, /upload(비동기, 파일당 20MB) + /jobs 폴링 또는 webhook 을 써주세요. 동기 호출은 처리가 180초를 넘으면 ocr_engine_timeout 이 나요 — 원인은 대개 화소 수가 아니라 밀도(작은 글씨가 빽빽한 다페이지 서류)라, 줄이기보다 1페이지 1이미지로 나누거나 비동기 경로를 써 주세요.
결과의 재현성
값 추출은 모델을 지나요. 같은 사진이라도 런마다 출력 구성이 흔들리는 게 실측돼 있어요 (어디까지를 한 값으로 돌려주는지, 명세를 어떻게 끊는지 같은 것들이요). 좌표 문자 대조와 normalized 파싱은 결정론적이지만 (추가 모델 호출 없음), 그 입력이 되는 추출 자체는 그렇지 않아요.
회계·감사처럼 "나중에 같은 숫자를 다시 댈 수 있어야 한다" 가 요건인 용도라면, 응답 JSON 을 그대로 보관하시고 확인도 저장된 값에 대해 하세요. 다시 읽는 건 내용이 바뀌었을 때나, 검토 결과를 버려도 될 때만으로 충분해요.
Idempotency-Key 는 24시간 동안만 같은 응답을 돌려주는 재전송 안전장치예요. 보관 수단이 아니고요 (보관은 데이터 취급 문서를 봐주세요).
오류
{
"error": {
"code": "validation_failed",
"message": "imageType is required",
"requestId": "req_xxx"
},
"details": {
/* optional, endpoint-specific context (e.g. /upload returns processable count) */
}
}
// error.code: validation_failed | bad_request | invalid_image | invalid_api_key
// | key_inactive | unauthorized | forbidden | not_found
// | insufficient_balance | rate_limited | ocr_engine_error
// | ocr_engine_timeout | storage_error | internal_errorHTTP 상태
구조화 OCR
이미지에서 이름 붙인 필드를 뽑아내요. fields 로 추출 스키마를 정하거나, autoFields 로 알아서 제안받을 수도 있어요.
동기 호출이라 응답이 올 때까지 연결을 유지하셔야 해요. 처리는 최장 180초이고 넘으면 504 ocr_engine_timeout 이에요 (과금되지 않아요). 실측으로는 한 장에 몇 초~수십 초에 들어오지만, 클라이언트 쪽 타임아웃은 여유 있게 잡아 주세요. 이 상한에 걸리는 건 대개 작은 글씨가 빽빽한 다페이지 서류이고 원인은 화소 수가 아니라 밀도예요 — 줄이면 오히려 못 읽으니 1페이지 1이미지로 나누시거나, 처리 시간에 여유가 있는 비동기 POST /upload 를 써주세요.
바디 파라미터
Base64 문자열 또는 이미지 URL. JSON 바디 상한은 28MB 이고 base64 는 파일의 약 1.33 배가 되니, 원본 이미지로는 20MB 쯤까지예요. 넘으면 413 (details.limitBytes / receivedBytes) 을 돌려드려요. 더 크면 URL 로 넘기거나 /upload (비동기, 파일당 20MB) 를 써주세요.
장변 4000px 를 넘는 이미지는 서버가 자동으로 축소해서 읽어요 — 좌표도 축소 후 페이지(data.image) 기준으로 돌아오니, 상한에 맞추려고 미리 화질을 낮춰 보내실 필요 없어요.
추출 스키마 배열이에요. autoFields 를 쓸 거면 생략해도 돼요. 값은 페이지에 적힌 읽기 그대로 돌아오고 요약하거나 바꿔 쓰지 않아요 — 그래야 좌표에 붙여서 검증할 수 있거든요.
다만 바이트 단위 복사본은 아니에요: values 는 모델이 읽은 문자열이고 문자 대조는 전각·괄호·공백을 접고 비교하기 때문에, (税抜)가 (税抜) 로 바뀌는 정도의 표기 차이는 통과해요. 정확 일치로 대조하실 거면 cells[path].evidence.printed_text (그 좌표에서 OCR 이 읽은 글자) 를 쓰세요.
기본값은 string 이에요. number / integer / date 를 선언해도 values 는 달라지지 않아요 — 타입이 모델에게 전달되는 일은 없어요 (타입을 알리면 그 형태의 값을 만들어 버리거든요). 선언한 타입이 만드는 건 normalized 라는 두 번째 층이고, 같은 읽기를 그 타입으로 해석한 값이 values 와 같은 모양으로 놓여요 ("¥13,220" → 13220, "令和8年8月16日" → "2026-08-16", "3袋" → 3). 해석은 결정론적이라 추가 모델 호출이 없어요. 해석하지 못한 값은 normalized 에서 null 이 되고 reason "type_mismatch" 가 붙는데, 대개 그건 오독의 신호예요.
반대로 선언하지 않는 게 나은 항목도 있어요. 수량 칸의 "一式", 지불기한의 "翌月末払い" 처럼 값이 아닌 표기가 정식으로 인쇄되는 항목이에요. 타입을 선언하면 서류로서는 맞는데도 매번 type_mismatch 로 검토에 올라와요 (error 가 conventional_token / relative_date 로 종류까지는 말해주지만, 목록에는 실려요). 늘 숫자·날짜가 들어오는 항목에만 타입을 선언하시고, 나머지는 string 으로 받아서 그쪽 업무 규칙으로 해석하시는 게 좋아요.
값 옆에 인쇄된 라벨이에요 (예: "합계"). 같은 값이 페이지에 여러 번 찍혀 있을 때 좌표를 그 라벨 옆 등장에 앵커해줘요. 라벨이 페이지에 정확히 1회 인쇄됐을 때만 작동하고, 못 찾으면 기존 탐색으로 돌아가면서 그 사실이 review.notes 에 issue: "label_unresolved" 로 실려요 (값은 돌아오니까 이 고지가 없으면 선언이 안 듣는 걸 알 수가 없거든요). "消費税(8%)"・"10%対象 小計" 처럼 여러 단어에 걸친 표기도 그대로 쓰실 수 있어요. required 처럼 모델에게는 전달되지 않아요 — 추출 텍스트는 그대로고 좌표 앵커만 바뀌어요. 후보가 여러 개면 배열로 주세요 (예: ["발행일", "발행년월일"]).
듣는 건 최상위의 string / number / integer / date 필드뿐이에요. array・object 본체와 그 children 에 준 label 은 읽히지 않고 무시돼요 (페이지 전체에 한 번뿐인 라벨은 반복되는 행 중 어느 것을 가리키는지 원리적으로 말할 수 없거든요). 명세표의 "수량"·"금액" 처럼 열 제목이 모든 행에 공유되는 경우엔 label 이 필요 없어요 — children 의 좌표는 행 단위로 풀려요. 행 안에서 위치를 알려주고 싶으면 description 을 써주세요 (예: "단가 오른쪽").
값 옆에 인쇄돼 있어야 할 어휘예요 (예: 수신처 회사명이면 ["御中", "様"], 발행원이면 ["登録番号", "〒", "TEL"]). 추출 후에 선언한 단어를 페이지에서 찾고, 값이 그 어느 것의 이웃에도 없으면 reason "near_mismatch" 가 서요.
이건 "완벽하게 읽었는데 다른 자리의 값을 골랐다" 클래스에 듣는 유일한 수단이에요. 장표에는 회사명이 2사 인쇄돼 있어서, 반대쪽을 골라도 문자 대조는 일치하니 verified: true 로 통과하거든요. enum 도 둘 다 정당한 마스터 값이면 못 갈라요. near 는 선택을 옳게 만드는 게 아니라, 틀린 선택을 보이게 만들어요.
판정은 값의 모든 출현 에 대해 해요 (v85). 어느 출현도 어휘 옆에 없으면 near_mismatch (어디에 있든 틀린 값), 옆에 있는 출현은 있는데 좌표가 붙은 게 다른 사본이면 near_ambiguous (어느 쪽을 가리키는지 미정 — 값 자체는 맞을 수 있어요). 같은 값이 두 곳에 인쇄된 장표에서 정답 값이 좌표가 붙은 자리에 따라 통과했다 실패했다 했기 때문이에요. 판정 내역은 cells[path].evidence.near 에 그대로 실려요.
match 로 "어휘가 인쇄물의 어디에 붙는 것을 인정할지" 를 지정하실 수 있어요: boundary (기본 — 낱말 자체이거나 낱말의 앞/뒤 끝) · suffix (御中・様・宛) · prefix (〒・TEL・登録番号) · standalone (붙어 오지 않는 어휘만) · anywhere (v81 동작). v85 에서 기본값이 바뀌었어요: v81 은 긴 낱말 안쪽이면 어디든 인정해서, 工事名 "中野様邸増築工事" 의 様 가 御中 을 한 번도 안 찍는 서식에서 수신처 판정의 증인이 됐거든요. v81 동작이 필요하시면 { "match": "anywhere" } 로 명시해 주세요. 어휘가 낱말 하나로 떨어져 인쇄되는 보통의 경우는 어느 모드에서도 영향받지 않아요.
선언한 단어가 페이지 어디에도 인쇄돼 있지 않으면 판정을 보류하고 review.notes 에 issue: "near_unresolved" 로 실려요 (御中 를 안 찍는 서식을 벌하지 않기 위해서예요). 그 서식이야말로 취급이 뒤바뀌는 자리라면 not_near 가 맡아요. label 처럼 모델에게는 전달되지 않아요. 이웃 창은 엔진 규정이고, 셀 높이를 단위로 가로 ±6배・세로 ±3배예요.
near 의 거울이에요 — 값 옆에 있으면 안 되는 어휘를 선언해요 (수신처 회사명에 ["登録番号", "TEL", "〒"]). 값이 그중 어느 것의 이웃에 있으면 reason "near_conflict" 가 서고, 옆에 있던 어휘와 거리가 cells[path].evidence.not_near 에 실려요. 형태도 match 지정도 near 와 같아요 (v85).
왜 둘 다 필요한가: near 는 식별 표식이 인쇄돼 있을 때만 말할 수 있어요. 그런데 당사자가 실제로 뒤바뀌는 건 수신처 행이 없는 사무용 폼이고, 거기엔 御中 이 아예 안 찍혀서 near 는 보류밖에 못 해요. 반면 발행원 블록은 무언가를 반드시 인쇄해요 (登録番号 / TEL / 〒). 그래서 닿는 말은 부정형이 돼요 — 발행원 블록 안에 앉아 있는 수신처는 발행원이에요.
어휘가 인쇄돼 있지 않은 건 위반이 아니라서, near 와 달리 보류하지 않고 그냥 통과해요 (review.notes 에도 안 실려요). 모델에게는 전달되지 않아요.
curl -X POST https://api.space-ocr.com/ocr/fields \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/receipt.jpg",
"imageType": "url",
"fields": [
{ "name": "store_name", "type": "string",
"description": "매장명" },
{ "name": "date", "type": "string",
"description": "거래일" },
{ "name": "payment_method", "type": "string",
"enum": ["現金", "クレジット", "電子マネー"],
"description": "지불 방법" },
{ "name": "invoice_no", "type": "string", "required": true,
"description": "전표 번호" },
{ "name": "items", "type": "array",
"description": "구매 항목",
"children": [
{ "name": "name", "type": "string" },
{ "name": "qty", "type": "string" },
{ "name": "price", "type": "string" }
]
},
{ "name": "total", "type": "number", "required": true,
"label": "합계",
"description": "합계" }
]
}'응답 필드
요청 스키마 그대로의 순수 사용자 데이터예요. 예약 키가 안 섞여서 그대로 DB 에 넣을 수 있어요. 값은 모델이 페이지에서 읽은 문자열이고 문자 대조로 인쇄물과 맞대어 둔 것이에요 — 바이트 단위 복사본은 아니라서, 정확 일치로 대조하실 거면 cells[path].evidence.printed_text 를 쓰세요.
값에는 반각 ¥(U+00A5) 같은 문자가 그대로 실려요. cp932 / Shift_JIS 로 인코딩하면 (CSV 출력 포함) 그 한 글자만으로 예외가 날 수 있으니 UTF-8 그대로 다뤄 주세요.
기울어진 스캔을 따라가는 4점이에요. box 와 항상 둘 다 붙어요. 화면에 테두리를 그릴 땐 이쪽을 쓰세요. 기준은 보내신 파일이 아니라 data.image 가 말하는 읽은 뒤의 페이지예요 — EXIF 로 옆으로 누운 사진은 정립시킨 다음 읽기 때문에 width 와 height 가 뒤바뀔 수 있어요 (4000×3000 으로 보내고 3000×4000 이 돌아오는 식). 픽셀 환산도 data.image 로 해주세요.
기울기 보정(deskew)은 하지 않아요 — 페이지를 돌려서 좌표를 다시 만드는 일은 없고, 돌아오는 좌표는 기울어진 채 읽은 입력 이미지의 좌표계예요. 기울어진 사진에서는 quad 가 그 기울기를 따라가요.
이 셀의 판정이에요. review 의 거울이라 둘이 어긋나지 않아요 — false 는 review 에 이유가 들어 있을 때(선언하신 규칙 위반을 포함해 이유 종류를 가리지 않아요), true 는 아무것도 안 섰고 대조가 실제로 돌았을 때, null 은 아무것도 안 섰지만 대조할 것도 없었을 때 (행 union 같은 기하 전용 항목) 예요. 게이트로는 이 하나면 충분해요.
문자 대조 자체(값이 이 좌표의 OCR 원문과 일치했는지, 두 독립 엔진의 합의)는 evidence.text_match 에 있어요. 그 불일치는 원래부터 전용 사유 text_mismatch 를 갖고 있어서 판정에서 잃는 게 없어요.
true 라고 해서 "요청하신 의미의 값" 이라는 뜻은 아니에요. 모델이 페이지의 다른 곳(근처의 보조 제목 같은)을 집어 왔다면 좌표는 그 집어 온 글자에 붙고 대조도 일치하니, 아무것도 안 서면 true 로 돌아와요. 좌표가 답하는 건 "이 값이 어디서 왔는가" 이지 "이게 맞는 항목인가" 가 아니에요 — 그건 label / near / enum 이 맡아요.
null 이면 통과, 값이 들어 있으면 사람 확인 권장이에요. reasons: type_mismatch | out_of_range | pattern_mismatch | near_mismatch | near_ambiguous | near_conflict | nobox | text_mismatch | crop_mismatch | low_ratio | weak_source | low_ocr_confidence | ambiguous_occurrence | overwide_box | missing. reasons 는 어긴 규칙 전부를 랭킹 순으로 담은 배열이고 0번이 대표예요 (길이가 1이어도 항상 배열). 앞의 여섯은 호출하신 쪽이 선언한 규칙을 어긴 거라, 엔진이 추정한 사유보다 위로 랭크돼요.
near_mismatch 와 near_ambiguous 는 서로 다른 질문의 답이에요: 앞은 이 값의 어느 출현도 선언 어휘 옆에 없다(어디에 있든 틀린 값), 뒤는 옆에 있는 출현은 있는데 좌표가 붙은 건 그게 아니다(어느 사본을 가리키는지 미정 — 값 자체는 맞을 수 있어요). 같은 값이 두 곳에 인쇄된 장표에서 정답 값이 좌표가 붙은 자리에 따라 통과했다 실패했다 하던 것을 가른 거예요 (v85). near_ambiguous 가 설 때 ambiguous_occurrence 는 함께 내보내지 않아요 — 같은 사실을 두 어휘로 말하는 셈이라서요.
화면을 만드실 땐 여기 나열된 전 종(그리고 이후 추가될 수 있는 코드)에 표시를 할당하고, 한 셀에 여러 사유가 동시에 서는 배열을 전제로 그리세요 — 일부만 대응하면 미대응 코드에서 화면이 깨져요. 모르는 코드는 제네릭 "검토 필요" 로 떨어뜨리는 게 안전해요.
스칼라 타입 (number / integer / date, 또는 pattern・enum 을 준 string) 을 선언한 필드에만 붙어요. data.normalized 의 그 리프가 null 이었던 이유가 여기 있어요. method 는 현재 항상 "deterministic" 이에요 (추가 모델 호출 없음).
error 는 못 읽은 이유를 종류로 말해줘요: not_numeric / not_an_integer / not_a_date 는 저희 오독일 수 있는 쪽 (l510 같은), no_year 는 연도가 인쇄되지 않은 날짜 (8/16・9月末日), conventional_token 은 서류가 원래 그렇게 인쇄한 관용 표기 (一式・各・別途・대시만 있는 칸), relative_date 는 다른 항목에 기대는 지불 조건 (翌月末払い・締日から60日) 이에요. 뒤 둘은 다시 읽어도 값이 안 나와요 — 확인으로 돌릴 대상이 아니라 그쪽 업무 규칙으로 처리할 대상이에요. 어느 쪽이든 reasons 는 type_mismatch 그대로라 건수 집계는 안 바뀌어요.
선언이 그대로 실행되지 못했을 때만 붙는 고지예요. 항목마다 path / issue / description 을 갖고, issue 로 분기하시면 돼요.
issue: "type_coerced" 는 이 API 가 지원하지 않는 타입을 선언하신 경우예요 (declared_type / applied_type 도 붙어요). 지원 타입은 string / number / integer / date / array / object 이고, 스칼라 타입은 조용히 처리돼서 해석된 값이 normalized 로 돌아와요.
issue: "label_unresolved" 는 선언하신 label 이 아무것도 앵커하지 못한 경우예요 (인쇄되지 않음・두 번 이상 있음・옆에 확신할 값이 없음). 값 자체는 기존 탐색으로 돌아오기 때문에, 이 고지가 없으면 선언이 안 듣고 있다는 걸 알 수가 없어요.
{
"status": "success",
"data": {
"values": {
"store_name": "슈퍼마켓 ABC",
"date": "2025-04-10",
"invoice_no": "",
"items": [
{ "name": "우유", "qty": "1", "price": "₩1,980" }
],
"total": "₩4,780"
},
"cells": {
"store_name": { "box": { "xmin": 14, "ymin": 36, "xmax": 210, "ymax": 58 },
"quad": [{"x":14,"y":36},{"x":210,"y":36},{"x":210,"y":58},{"x":14,"y":58}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 0.98, "ocr_confidence": 0.96 } },
"date": { "box": { "xmin": 14, "ymin": 80, "xmax": 180, "ymax": 102 },
"quad": [{"x":14,"y":80},{"x":180,"y":80},{"x":180,"y":102},{"x":14,"y":102}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "match_ratio": 1.0, "ocr_confidence": 0.99 } },
"items[0]": { "box": { "xmin": 263, "ymin": 460, "xmax": 738, "ymax": 523 },
"quad": [{"x":263,"y":460},{"x":738,"y":460},{"x":738,"y":523},{"x":263,"y":523}],
"verified": null, "review": null,
"evidence": { "source": "vision_symbol_match", "match_ratio": 1.0 } },
"items[0].name": { "box": { "xmin": 263, "ymin": 460, "xmax": 503, "ymax": 492 },
"quad": [{"x":263,"y":460},{"x":503,"y":460},{"x":503,"y":492},{"x":263,"y":492}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "match_ratio": 1.0, "ocr_confidence": 0.97 } },
"items[0].qty": { "box": { "xmin": 333, "ymin": 460, "xmax": 338, "ymax": 490 },
"quad": [{"x":333,"y":460},{"x":338,"y":460},{"x":338,"y":490},{"x":333,"y":490}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0, "ocr_confidence": 0.94 } },
"items[0].price": { "box": { "xmin": 693, "ymin": 460, "xmax": 738, "ymax": 488 },
"quad": [{"x":693,"y":460},{"x":738,"y":460},{"x":738,"y":488},{"x":693,"y":488}],
"verified": false,
"review": { "reasons": ["text_mismatch"] },
"evidence": { "text_match": false, "source": "vision_symbol_match", "match_ratio": 0.62, "ocr_confidence": 0.88 } },
"total": { "box": { "xmin": 380, "ymin": 720, "xmax": 530, "ymax": 742 },
"quad": [{"x":380,"y":720},{"x":530,"y":720},{"x":530,"y":742},{"x":380,"y":742}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "vision_symbol_match", "match_ratio": 1.0, "ocr_confidence": 0.98 },
"normalized": { "value": 4780, "type": "number", "method": "deterministic" } }
},
"review": {
"unit": "field",
"declared": 7,
"returned": 6,
"boxed": 6,
"verified": 5,
"flagged": [
{ "path": "items[0].price", "reasons": ["text_mismatch"] },
{ "path": "invoice_no", "reasons": ["missing"] }
],
"by_reason": { "text_mismatch": 1, "missing": 1 }
},
// 선언한 타입은 values 를 건드리지 않고 이 층으로 나와요
"normalized": { "total": 4780 },
"image": { "width": 1654, "height": 2339 }
}
}마크다운 변환
레이아웃을 살린 채로 이미지를 마크다운으로 바꿔요. 제목·문단·목록·표가 요소로 나오고, 요소마다 좌표가 붙어요.
동기 호출이라 응답이 올 때까지 연결을 유지하셔야 해요. 처리는 최장 180초이고 넘으면 504 ocr_engine_timeout 이에요 (과금되지 않아요). 실측으로는 한 장에 몇 초~수십 초에 들어오지만, 클라이언트 쪽 타임아웃은 여유 있게 잡아 주세요. 이 상한에 걸리는 건 대개 작은 글씨가 빽빽한 다페이지 서류이고 원인은 화소 수가 아니라 밀도예요 — 줄이면 오히려 못 읽으니 1페이지 1이미지로 나누시거나, 처리 시간에 여유가 있는 비동기 POST /upload 를 써주세요.
바디 파라미터
Base64 문자열 또는 이미지 URL. JSON 바디 상한은 28MB 이고 base64 는 파일의 약 1.33 배가 되니, 원본 이미지로는 20MB 쯤까지예요. 넘으면 413 (details.limitBytes / receivedBytes) 을 돌려드려요. 더 크면 URL 로 넘기거나 /upload (비동기, 파일당 20MB) 를 써주세요.
장변 4000px 를 넘는 이미지는 서버가 자동으로 축소해서 읽어요 — 좌표도 축소 후 페이지(data.image) 기준으로 돌아오니, 상한에 맞추려고 미리 화질을 낮춰 보내실 필요 없어요.
curl -X POST https://api.space-ocr.com/ocr/markdown \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image": "https://example.com/report.jpg",
"imageType": "url"
}'응답 필드
기울어진 스캔을 따라가는 4점이에요. box 와 항상 둘 다 붙어요. 화면에 테두리를 그릴 땐 이쪽을 쓰세요. 기준은 보내신 파일이 아니라 data.image 가 말하는 읽은 뒤의 페이지예요 — EXIF 로 옆으로 누운 사진은 정립시킨 다음 읽기 때문에 width 와 height 가 뒤바뀔 수 있어요 (4000×3000 으로 보내고 3000×4000 이 돌아오는 식). 픽셀 환산도 data.image 로 해주세요.
기울기 보정(deskew)은 하지 않아요 — 페이지를 돌려서 좌표를 다시 만드는 일은 없고, 돌아오는 좌표는 기울어진 채 읽은 입력 이미지의 좌표계예요. 기울어진 사진에서는 quad 가 그 기울기를 따라가요.
이 셀의 판정이에요. review 의 거울이라 둘이 어긋나지 않아요 — false 는 review 에 이유가 들어 있을 때(선언하신 규칙 위반을 포함해 이유 종류를 가리지 않아요), true 는 아무것도 안 섰고 대조가 실제로 돌았을 때, null 은 아무것도 안 섰지만 대조할 것도 없었을 때 (표 요소 자체 — 셀 각각은 검증돼요) 예요. 게이트로는 이 하나면 충분해요.
문자 대조 자체(값이 이 좌표의 OCR 원문과 일치했는지, 두 독립 엔진의 합의)는 evidence.text_match 에 있어요. 그 불일치는 원래부터 전용 사유 text_mismatch 를 갖고 있어서 판정에서 잃는 게 없어요.
true 라고 해서 "요청하신 의미의 값" 이라는 뜻은 아니에요. 모델이 페이지의 다른 곳(근처의 보조 제목 같은)을 집어 왔다면 좌표는 그 집어 온 글자에 붙고 대조도 일치하니, 아무것도 안 서면 true 로 돌아와요. 좌표가 답하는 건 "이 값이 어디서 왔는가" 이지 "이게 맞는 항목인가" 가 아니에요 — 그건 label / near / enum 이 맡아요.
null 이면 통과, 값이 들어 있으면 사람 확인 권장이에요. reasons: type_mismatch | out_of_range | pattern_mismatch | near_mismatch | near_ambiguous | near_conflict | nobox | text_mismatch | crop_mismatch | low_ratio | weak_source | low_ocr_confidence | ambiguous_occurrence | overwide_box | missing. reasons 는 어긴 규칙 전부를 랭킹 순으로 담은 배열이고 0번이 대표예요 (길이가 1이어도 항상 배열). 앞의 여섯은 호출하신 쪽이 선언한 규칙을 어긴 거라, 엔진이 추정한 사유보다 위로 랭크돼요.
near_mismatch 와 near_ambiguous 는 서로 다른 질문의 답이에요: 앞은 이 값의 어느 출현도 선언 어휘 옆에 없다(어디에 있든 틀린 값), 뒤는 옆에 있는 출현은 있는데 좌표가 붙은 건 그게 아니다(어느 사본을 가리키는지 미정 — 값 자체는 맞을 수 있어요). 같은 값이 두 곳에 인쇄된 장표에서 정답 값이 좌표가 붙은 자리에 따라 통과했다 실패했다 하던 것을 가른 거예요 (v85). near_ambiguous 가 설 때 ambiguous_occurrence 는 함께 내보내지 않아요 — 같은 사실을 두 어휘로 말하는 셈이라서요.
화면을 만드실 땐 여기 나열된 전 종(그리고 이후 추가될 수 있는 코드)에 표시를 할당하고, 한 셀에 여러 사유가 동시에 서는 배열을 전제로 그리세요 — 일부만 대응하면 미대응 코드에서 화면이 깨져요. 모르는 코드는 제네릭 "검토 필요" 로 떨어뜨리는 게 안전해요.
{
"status": "success",
"data": {
"values": {
"markdown": "# 분기 보고서\n\n매출은 전년 동기 대비 증가했다.\n\n| 항목 | 금액 |\n| --- | --- |\n| 매출 | 12,000 |",
"elements": [
{ "type": "heading", "level": 1, "text": "분기 보고서" },
{ "type": "paragraph", "text": "매출은 전년 동기 대비 증가했다." },
{ "type": "table", "rows": 2, "cols": 2, "cells": [
{ "row": 0, "col": 0, "header": true, "text": "항목" },
{ "row": 0, "col": 1, "header": true, "text": "금액" },
{ "row": 1, "col": 0, "header": false, "text": "매출" },
{ "row": 1, "col": 1, "header": false, "text": "12,000" }
] }
]
},
"cells": {
"elements[0]": { "box": { "xmin": 60, "ymin": 48, "xmax": 520, "ymax": 92 },
"quad": [{"x":60,"y":48},{"x":520,"y":48},{"x":520,"y":92},{"x":60,"y":92}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "ocr_confidence": 0.98 } },
"elements[1]": { "box": { "xmin": 60, "ymin": 120, "xmax": 900, "ymax": 160 },
"quad": [{"x":60,"y":120},{"x":900,"y":120},{"x":900,"y":160},{"x":60,"y":160}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id" } },
"elements[2]": { "box": { "xmin": 60, "ymin": 200, "xmax": 640, "ymax": 320 },
"quad": [{"x":60,"y":200},{"x":640,"y":200},{"x":640,"y":320},{"x":60,"y":320}],
"verified": null, "review": null, "evidence": {} },
"elements[2].cells[0]": { "box": { "xmin": 60, "ymin": 200, "xmax": 350, "ymax": 260 },
"quad": [{"x":60,"y":200},{"x":350,"y":200},{"x":350,"y":260},{"x":60,"y":260}],
"verified": true, "review": null, "evidence": { "text_match": true, "source": "token_id" } },
"elements[2].cells[1]": { "box": { "xmin": 350, "ymin": 200, "xmax": 640, "ymax": 260 },
"quad": [{"x":350,"y":200},{"x":640,"y":200},{"x":640,"y":260},{"x":350,"y":260}],
"verified": false,
"review": { "reasons": ["text_mismatch"] },
"evidence": { "text_match": false, "source": "token_id", "ocr_confidence": 0.71 } },
"elements[2].cells[2]": { "box": { "xmin": 60, "ymin": 260, "xmax": 350, "ymax": 320 },
"quad": [{"x":60,"y":260},{"x":350,"y":260},{"x":350,"y":320},{"x":60,"y":320}],
"verified": true, "review": null, "evidence": { "text_match": true, "source": "token_id" } },
"elements[2].cells[3]": { "box": { "xmin": 350, "ymin": 260, "xmax": 640, "ymax": 320 },
"quad": [{"x":350,"y":260},{"x":640,"y":260},{"x":640,"y":320},{"x":350,"y":320}],
"verified": true, "review": null, "evidence": { "text_match": true, "source": "token_id" } }
},
"review": {
"unit": "element",
"total": 6,
"boxed": 6,
"verified": 5,
"flagged": [{ "path": "elements[2].cells[1]", "reasons": ["text_mismatch"] }],
"by_reason": { "text_mismatch": 1 },
"coverage": { "recovered_blocks": 0, "vision_tokens": 40, "tokens_claimed": 40, "token_coverage": 1.0 }
},
"image": { "width": 1654, "height": 2339 }
}
}원문 텍스트 OCR
스키마도 마크다운 문법도 없이 문서의 글자만 전부 돌려줘요. 모델이 이미지를 보고 진짜 읽기순서로 블록을 정렬하기 때문에, 다단 조판이나 기울어진 스캔에서도 문장이 뒤섞이지 않아요.
동기 호출이라 응답이 올 때까지 연결을 유지하셔야 해요. 처리는 최장 180초이고 넘으면 504 ocr_engine_timeout 이에요 (과금되지 않아요). 실측으로는 한 장에 몇 초~수십 초에 들어오지만, 클라이언트 쪽 타임아웃은 여유 있게 잡아 주세요. 이 상한에 걸리는 건 대개 작은 글씨가 빽빽한 다페이지 서류이고 원인은 화소 수가 아니라 밀도예요 — 줄이면 오히려 못 읽으니 1페이지 1이미지로 나누시거나, 처리 시간에 여유가 있는 비동기 POST /upload 를 써주세요.
바디 파라미터
Base64 문자열 또는 이미지 URL. JSON 바디 상한은 28MB 이고 base64 는 파일의 약 1.33 배가 되니, 원본 이미지로는 20MB 쯤까지예요. 넘으면 413 (details.limitBytes / receivedBytes) 을 돌려드려요. 더 크면 URL 로 넘기거나 /upload (비동기, 파일당 20MB) 를 써주세요.
장변 4000px 를 넘는 이미지는 서버가 자동으로 축소해서 읽어요 — 좌표도 축소 후 페이지(data.image) 기준으로 돌아오니, 상한에 맞추려고 미리 화질을 낮춰 보내실 필요 없어요.
curl -X POST https://api.space-ocr.com/ocr/text -H "Authorization: Bearer YOUR_API_KEY" -H "Content-Type: application/json" -d '{
"image": "https://example.com/note.jpg",
"imageType": "url",
"includeBlocks": true
}'응답 필드
기울어진 스캔을 따라가는 4점이에요. box 와 항상 둘 다 붙어요. 화면에 테두리를 그릴 땐 이쪽을 쓰세요. 기준은 보내신 파일이 아니라 data.image 가 말하는 읽은 뒤의 페이지예요 — EXIF 로 옆으로 누운 사진은 정립시킨 다음 읽기 때문에 width 와 height 가 뒤바뀔 수 있어요 (4000×3000 으로 보내고 3000×4000 이 돌아오는 식). 픽셀 환산도 data.image 로 해주세요.
기울기 보정(deskew)은 하지 않아요 — 페이지를 돌려서 좌표를 다시 만드는 일은 없고, 돌아오는 좌표는 기울어진 채 읽은 입력 이미지의 좌표계예요. 기울어진 사진에서는 quad 가 그 기울기를 따라가요.
이 셀의 판정이에요. review 의 거울이라 둘이 어긋나지 않아요 — false 는 review 에 이유가 들어 있을 때(선언하신 규칙 위반을 포함해 이유 종류를 가리지 않아요), true 는 아무것도 안 섰고 대조가 실제로 돌았을 때, null 은 아무것도 안 섰지만 대조할 것도 없었을 때 (기하 전용 항목) 예요. 게이트로는 이 하나면 충분해요.
문자 대조 자체(값이 이 좌표의 OCR 원문과 일치했는지, 두 독립 엔진의 합의)는 evidence.text_match 에 있어요. 그 불일치는 원래부터 전용 사유 text_mismatch 를 갖고 있어서 판정에서 잃는 게 없어요.
true 라고 해서 "요청하신 의미의 값" 이라는 뜻은 아니에요. 모델이 페이지의 다른 곳(근처의 보조 제목 같은)을 집어 왔다면 좌표는 그 집어 온 글자에 붙고 대조도 일치하니, 아무것도 안 서면 true 로 돌아와요. 좌표가 답하는 건 "이 값이 어디서 왔는가" 이지 "이게 맞는 항목인가" 가 아니에요 — 그건 label / near / enum 이 맡아요.
null 이면 통과, 값이 들어 있으면 사람 확인 권장이에요. reasons: type_mismatch | out_of_range | pattern_mismatch | near_mismatch | near_ambiguous | near_conflict | nobox | text_mismatch | crop_mismatch | low_ratio | weak_source | low_ocr_confidence | ambiguous_occurrence | overwide_box | missing. reasons 는 어긴 규칙 전부를 랭킹 순으로 담은 배열이고 0번이 대표예요 (길이가 1이어도 항상 배열). 앞의 여섯은 호출하신 쪽이 선언한 규칙을 어긴 거라, 엔진이 추정한 사유보다 위로 랭크돼요.
near_mismatch 와 near_ambiguous 는 서로 다른 질문의 답이에요: 앞은 이 값의 어느 출현도 선언 어휘 옆에 없다(어디에 있든 틀린 값), 뒤는 옆에 있는 출현은 있는데 좌표가 붙은 건 그게 아니다(어느 사본을 가리키는지 미정 — 값 자체는 맞을 수 있어요). 같은 값이 두 곳에 인쇄된 장표에서 정답 값이 좌표가 붙은 자리에 따라 통과했다 실패했다 하던 것을 가른 거예요 (v85). near_ambiguous 가 설 때 ambiguous_occurrence 는 함께 내보내지 않아요 — 같은 사실을 두 어휘로 말하는 셈이라서요.
화면을 만드실 땐 여기 나열된 전 종(그리고 이후 추가될 수 있는 코드)에 표시를 할당하고, 한 셀에 여러 사유가 동시에 서는 배열을 전제로 그리세요 — 일부만 대응하면 미대응 코드에서 화면이 깨져요. 모르는 코드는 제네릭 "검토 필요" 로 떨어뜨리는 게 안전해요.
{
"status": "success",
"data": {
"values": {
"text": "사쿠라상사 주식회사\n청구서\n합계 1,451원",
"blocks": [
{ "text": "사쿠라상사 주식회사" }
]
},
"cells": {
"blocks[0]": { "box": { "xmin": 60, "ymin": 48, "xmax": 470, "ymax": 92 },
"quad": [{"x":60,"y":48},{"x":470,"y":48},{"x":470,"y":92},{"x":60,"y":92}],
"verified": true, "review": null,
"evidence": { "text_match": true, "source": "token_id", "ocr_confidence": 0.98 } }
},
"review": {
"unit": "block",
"total": 12,
"boxed": 12,
"verified": 11,
"flagged": [{ "path": "blocks[7]", "reasons": ["text_mismatch"] }],
"by_reason": { "text_mismatch": 1 },
"coverage": { "recovered_blocks": 0, "vision_tokens": 96, "tokens_claimed": 96, "token_coverage": 1.0 }
},
"image": { "width": 1654, "height": 2339 },
"source": "llm"
}
}트리 조회
쿼리 파라미터
curl https://api.space-ocr.com/space?path=/&depth=1 \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{
"path": "/",
"depth": 1,
"items": [
{ "path": "/invoices", "name": "invoices", "type": "folder", "createdAt": 1716700000000 },
{ "path": "/memo_2024", "name": "메모", "type": "memo",
"uniqueKey": "...", "createdAt": 1716700000000, "extensions": null }
]
}
// type: folder | sheet | doc | memo | img. 폴더가 아닌 항목은 uniqueKey / extensions 도 같이 와요.내용 조회
폴더/시트/문서 묶음/메모/이미지 모든 종류의 내용을 돌려드려요. 문서 묶음은 pages 배열, 시트는 rows 배열로 나와요. 쿼리(where / sort / select / limit / offset / boxes)는 시트에서만 동작해요 — 다른 종류에 붙이면 무시되고 전체가 그대로 나와요.
시트 행은 업로드 시각(createdAt) 오름차순으로 나와요. POST /edit・POST /remove 의 row: N 과 같은 순서라서, 응답의 N 번째 행이 곧 row: N 이에요.
쿼리 파라미터
# 다중 where (AND) + 정렬 + 투영 + 페이지네이션
curl "https://api.space-ocr.com/view?path=/invoices/sheet1\
&where=total>=10000\
&where=vendor~ABC\
&sort=-invoice_date\
&select=vendor,total,invoice_date\
&limit=20&offset=0" \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
// type=sheet
{
"type": "sheet",
"path": "/invoices/sheet1",
"name": "sheet1",
"columns": [ /* ... */ ],
"total": 128, // 시트 전체 행 수
"matched": 12, // where 통과한 행 수
"offset": 0,
"limit": 20,
"nextOffset": 20, // 다음 페이지 / 끝나면 null
"rows": [
{
"rowKey": "img_abc",
"name": "invoice_2025_04_10.jpg",
"createdAt": 1744243200000, // 업로드 시각 / 기본 행 순서
"imageUrl": "https://...",
"ocrStatus": "done",
"values": { "vendor": "ABC Corp", "total": "12000", "invoice_date": "2025-04-10" },
"cells": { /* POST /ocr/fields 와 같은 box / quad / verified / review — boxes=0 이면 생략 */ },
"review": { "unit": "field" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
]
}
// type=folder
{
"type": "folder",
"path": "/invoices",
"items": [
{ "path": "/invoices/2024", "name": "2024", "type": "folder" },
{ "path": "/invoices/Kvho45OXMKw…", "name": "sheet1", "type": "sheet", "uniqueKey": "Kvho45OXMKw…" }
]
}
// type=doc — 페이지 전체가 그대로 나와요 (where / sort / limit / offset / select / boxes 는 무시돼요).
// type=doc (mode=markdown)
{
"type": "doc",
"path": "/reports/quarterly",
"name": "quarterly",
"mode": "markdown",
"total": 2,
"pages": [
{
"pageKey": "img_abc",
"name": "page1.jpg",
"imageUrl": "https://...",
"ocrStatus": "done",
"values": { "markdown": "# ...", "elements": [ /* ... */ ] },
"cells": { /* POST /ocr/markdown 과 같은 box / quad / verified / review */ },
"review": { "unit": "element" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
]
}
// type=doc (mode=text)
{
"type": "doc",
"path": "/notes/scan",
"name": "scan",
"mode": "text",
"total": 1,
"pages": [
{
"pageKey": "img_def",
"name": "note.jpg",
"imageUrl": "https://...",
"ocrStatus": "done",
"values": { "text": "...", "blocks": [ /* ... */ ] },
"cells": { /* POST /ocr/text 와 같은 box / quad / verified / review */ },
"review": { "unit": "block" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
]
}
// type=memo
{ "type": "memo", "path": "...", "name": "todo", "text": "..." }
// type=img
{ "type": "img", "path": "...", "name": "...", "imageUrl": "...", "ocrStatus": "done" }생성
바디 파라미터
# sheet
curl -X POST https://api.space-ocr.com/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"path": "/invoices",
"type": "sheet",
"name": "sheet1",
"columns": [
{ "id": "amount", "name": "amount", "type": "string", "required": true },
{ "id": "date", "name": "date", "type": "string" }
],
"prompt": "청구서에서 금액과 날짜 추출"
}'응답 필드
// HTTP 201 Created
// sheet/memo は uniqueKey が path に組み込まれて返却される
{ "path": "/invoices/Kvho45OXMKw…", "type": "sheet", "uniqueKey": "Kvho45OXMKw…" }
// 만들어지는 즉시 item.created 웹훅이 발사돼요. Idempotency-Key 헤더를 붙이면
// 24h 안의 재요청은 같은 응답이 그대로 와요.
// required: true 인 컬럼(위의 amount)은 이후 업로드에서 값이 비면
// 그 행의 review.flagged 에 reason "missing" 으로 나타나요.이미지 업로드
폼 필드 (multipart)
curl -X POST https://api.space-ocr.com/upload \
-H "Authorization: Bearer YOUR_API_KEY" \
-F "path=/invoices/sheet1" \
-F "files=@invoice1.jpg" \
-F "files=@invoice2.jpg"응답 필드
// async (default)
{
"path": "/invoices/sheet1",
"jobs": [
{ "uniqueKey": "...", "originalName": "invoice1.jpg", "jobId": "job_...", "status": "pending" },
{ "uniqueKey": "...", "originalName": "invoice2.jpg", "jobId": "job_...", "status": "pending" }
]
}
// 문서 묶음에 업로드한 경우 — jobs 모양은 같고, 묶음의 mode 가 변환 방식을 정합니다
{
"path": "/reports/quarterly",
"jobs": [
{ "uniqueKey": "...", "originalName": "page1.jpg", "jobId": "job_...", "status": "pending" }
]
}
// wait=true — jobs 가 아니라 results 로 와요
{
"path": "/invoices/sheet1",
"results": [
{ "uniqueKey": "...", "originalName": "invoice1.jpg", "jobId": "job_...",
"status": "done", "mode": "sheet",
"result": { /* { values, cells, review, image } — GET /jobs 와 같은 v2 구조 */ } },
{ "uniqueKey": "...", "originalName": "invoice2.jpg", "jobId": "job_...",
"status": "pending" } // 30s 안에 못 끝난 건 /jobs 로 폴링
]
}
// 402 — 잔액 부족
{
"error": { "code": "insufficient_balance", "message": "...", "requestId": "req_..." },
"details": {
"requested": 5,
"processable": 3,
"breakdown": {
"freeRemaining": 0,
"flatfeeRemaining": 3,
"balance": 0,
"perCallCost": 1,
"currency": "scans"
}
}
}시트 행/메모 편집
바디 파라미터
# sheet
curl -X POST https://api.space-ocr.com/edit \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"/invoices/sheet1","row":"img_abc","column":"amount","value":"12000"}'
# memo
curl -X POST https://api.space-ocr.com/edit \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"/todo","text":"새 본문"}'응답 필드
{ "ok": true, "patched": { "row": "img_abc", "column": "amount", "value": "12000" } }삭제 (cascade)
바디 파라미터
curl -X POST https://api.space-ocr.com/remove \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"path":"/invoices/2024"}'응답 필드
{ "ok": true }OCR 잡 폴링
패스 파라미터
curl https://api.space-ocr.com/jobs/job_xxx \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{
"jobId": "job_xxx",
"status": "done",
"uniqueKey": "img_abc",
"path": "/invoices/sheet1/img_abc",
"sheetRef": "Kvho45OXMKw…",
"docRef": null,
"mode": "sheet",
"result": {
"values": { "amount": "12000", "date": "2025-04-10" },
"cells": { /* POST /ocr/fields 와 같은 box / quad / verified / review */ },
"review": { "unit": "field" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
}잔액 및 무료 할당
curl https://api.space-ocr.com/amount \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
충전 잔액이에요. 단위는 통화가 아니라 스캔 수예요.
차감은 무료 할당 → 정액 플랜 → 잔액 순이라, 무료 할당이 남아 있는 동안 이 값은 줄지 않아요. 잔량 표시를 만드신다면 free.remaining 과 balance 를 둘 다 보여주세요 — balance 만 보여주면 무료 할당을 쓰는 동안 내내 안 줄어드는 숫자가 돼요.
{
"free": { // 매월 무료 할당
"used": 12,
"limit": 100,
"remaining": 88,
"cycleStart": 1716700000000,
"cycleEnd": 1719378400000
},
"flatfee": { // 정액 플랜 (미가입이면 enabled:false)
"enabled": true,
"used": 340,
"limit": 3000,
"remaining": 2660,
"cycleStart": 1716700000000,
"cycleEnd": 1719378400000,
"nextBillingAt": 1719378400000,
"interval": "monthly",
"renewal": true,
"plan": "pro"
},
"balance": 1240, // 충전 잔액 (스캔 수)
"currency": "scans", // 잔액 단위는 통화가 아니라 스캔 수예요
"perCallCost": 1 // 1 스캔 = 1 콜
}
// 처리 가능 매수 = free.remaining + (flatfee.enabled ? flatfee.remaining : 0) + balance.
// 소진 순서도 이 순서예요 (무료 → 정액 → 잔액).헬스 체크
curl https://api.space-ocr.com/health응답 필드
이 API 자체의 변경 이력이에요. 최신 우선이고 엔트리마다 version・date・changes[] 가 있어요.
engine.changelog 와 헷갈리기 쉬우니 한 번만 정리할게요. 최상위 changelog 는 version(v2.x — 엔드포인트・파라미터・응답 키의 계약)과 짝이고, engine.changelog 는 engine.version(vNN — 읽기의 내용)과 짝이에요. 축이 달라서 한쪽만 움직이는 일이 있어요.
{
"status": "ok",
"version": "v2.8",
"time": 1787298502910,
"changelog": [
{ "version": "v2.8", "date": "2026-08-21",
"changes": ["Field extraction responses carry a new evidence key, `printed_text` …", "…"] }
],
"engine": {
"version": "v83",
"build": { "sha": "52b9a92", "deployed_at": "2026-08-21T06:59:16Z" },
"changelog": [
{ "version": "v83", "date": "2026-08-21",
"changes": ["New evidence key `cells[path].evidence.printed_text` …", "…"] }
]
}
}개요
이벤트
페이로드 예시 — ocr.completed
{
"event": "ocr.completed",
"deliveryId": "dlv_xxx",
"occurredAt": 1716700000000,
"apiVersion": "v2.7",
"data": {
"uid": "...",
"path": "/invoices/sheet1/img_abc",
"parentPath": "/invoices/sheet1",
"uniqueKey": "img_abc",
"sheetRef": "sht_xxx",
"docRef": null,
"mode": "sheet",
"result": {
"values": { "amount": "12000", "date": "2025-04-10" },
"cells": { /* box / quad / verified / review */ },
"review": { "unit": "field" /* ... */ },
"image": { "width": 1654, "height": 2339 }
}
}
}전달 헤더
X-Spaceocr-Signature: t=<unix_ms>,v1=<hex>
X-Spaceocr-Timestamp: <unix_ms>
X-Spaceocr-Event: ocr.completed
X-Spaceocr-Delivery: dlv_<id>
Content-Type: application/json서명 검증
import crypto from "crypto";
export function verify(secret, headers, rawBody) {
const sig = headers["x-spaceocr-signature"] || "";
const m = sig.match(/^t=(\d+),v1=([a-f0-9]+)$/);
if (!m) return false;
const [, t, v1] = m;
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(v1, "hex"),
);
}재시도 정책
현재 webhook 설정
curl https://api.space-ocr.com/webhook \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{
"configured": true,
"url": "https://example.com/hooks/space-ocr",
"active": true,
"secretMasked": "••••a1b2",
"createdAt": 1716700000000,
"updatedAt": 1716700000000
}
// 설정 안 돼 있을 때
{ "configured": false }webhook 설정 생성·갱신
바디 파라미터
curl -X PUT https://api.space-ocr.com/webhook \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/space-ocr","active":true}'응답 필드
{
"configured": true,
"url": "https://example.com/hooks/space-ocr",
"active": true,
"secretMasked": "••••a1b2",
"secret": "kJ8s…", // 새로 발급/회전할 때만, 이 한 번만 평문
"createdAt": 1716700000000,
"updatedAt": 1716700000000
}webhook 설정 삭제
curl -X DELETE https://api.space-ocr.com/webhook \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{ "ok": true }테스트 이벤트 발사
curl -X POST https://api.space-ocr.com/webhook/test \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{ "ok": true, "deliveryId": "dlv_xxx" }최근 전달 이력
쿼리 파라미터
curl https://api.space-ocr.com/webhooks/deliveries \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{
"items": [
{
"deliveryId": "dlv_xxx",
"event": "ocr.completed",
"url": "https://example.com/hooks/space-ocr",
"path": "/invoices/sheet1/img_abc",
"uniqueKey": "img_abc",
"status": "success", // pending | success | dead
"attempts": 1, // 시도 횟수
"lastAttempt": {
"at": 1716700000000,
"attemptIndex": 0,
"responseStatus": 200,
"error": null,
"durationMs": 143,
"responsePreview": "ok"
},
"occurredAt": 1716700000000,
"nextAttemptAt": null,
"completedAt": 1716700000143
}
]
}배달 상세
패스 파라미터
curl https://api.space-ocr.com/webhooks/deliveries/dlv_xxx \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{
"deliveryId": "dlv_xxx",
"event": "ocr.completed",
"occurredAt": 1716700000000,
"payload": { /* full event body */ },
"attempts": [
{ "at": 1716700000000, "responseStatus": 200, "ok": true }
]
}수동 재발송
패스 파라미터
curl -X POST https://api.space-ocr.com/webhooks/deliveries/dlv_xxx/redeliver \
-H "Authorization: Bearer YOUR_API_KEY"응답 필드
{ "ok": true, "deliveryId": "dlv_xxx" }
// 같은 deliveryId 를 그대로 다시 씁니다 (새 ID 를 만들지 않아요). 배달 로그의
// status 가 pending 으로 돌아가고 attempts 에 시도가 덧붙습니다.개요
연결
# Claude Code
claude mcp add --transport http space-ocr https://mcp.space-ocr.com/mcp \
--header "Authorization: Bearer YOUR_API_KEY"Cursor / VS Code / Windsurf (mcp.json)
{
"mcpServers": {
"space-ocr": {
"url": "https://mcp.space-ocr.com/mcp",
"headers": { "Authorization": "Bearer YOUR_API_KEY" }
}
}
}