space ocr

API 문서

https://api.space-ocr.com
#

소개

space ocr API 는 문서 사진을 이름 붙인 필드·마크다운·원문 텍스트 중 원하는 형태로 읽고, 값마다 읽어 낸 좌표와 검증 플래그를 함께 돌려줘요. 그 결과를 보관·조회하는 게 MySpace 고요. spocr_* 로 시작하는 API 키 하나면 REST 엔드포인트랑 이벤트 webhook 까지 한 번에 써요.

REST 기반, JSON, CORS 다 돼요. 가변 길이 배치나 비동기 처리는 Jobs / Webhooks 섹션을 보세요.

#

5분 퀵스타트

키 발급 → curl 복붙 → JSON. 첫 호출까지 5분이면 충분해요. 무료 할당은 매월 100건이에요.

① 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 로 올린 문서도 같은 시트에 나타나서, 자동 처리와 눈으로 하는 검수를 한자리에서 다룰 수 있어요.

요청
1
2
3
4
5
6
7
8
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" }]
  }'
#

인증

모든 요청에 Authorization 헤더로 Bearer <API키> 를 실어서 보내요. 키 발급·폐기는 Developer → API Keys 에서 할 수 있어요.

키 형식은 spocr_ 로 시작해요. 혹시 노출되면 바로 폐기해주세요.

요청
1
2
curl https://api.space-ocr.com/amount \
  -H "Authorization: Bearer YOUR_API_KEY"
#

Base URL

프로덕션 base URL 은 하나예요. 버전 관리는 키랑 이벤트 페이로드의 apiVersion 으로 해요.
1
2
3
4
5
# Production
https://api.space-ocr.com

# OpenAPI spec
https://api.space-ocr.com/openapi.json
#

Rate limits

60 req/min/key, 600 req/min/uid 까지 받아요. 초과하면 HTTP 429 와 Retry-After 헤더로 몇 초 기다리면 되는지 알려드려요.

응답엔 항상 X-Request-Id (req_xxx) 와 X-RateLimit-Remaining (이번 분에 남은 호출 수) 가 붙어요. 문의하실 때 X-Request-Id 를 같이 주시면 좋아요.

/ocr/fields・/create・/upload 에서는 Idempotency-Key 헤더를 쓸 수 있어요. 같은 키로 다시 보내면 24h 동안 캐시된 응답이 그대로 와요. 이땐 X-Idempotent-Replay: true 헤더가 붙어요.

#

이미지 크기와 응답 시간

응답 시간을 정하는 건 주로 문서의 밀도와 읽어 내게 하는 양(선언한 필드 수)이에요 — 이미지 픽셀 수가 아니에요. 관측 분포는 p50 7.2초 / p90 10.5초인데, 프로덕션 전체 집계라 대부분은 몇 항목짜리 선언이에요. 수십 항목을 선언한 고밀도 호출은 이 분포 위쪽에 와요 (SLA 는 아니에요).

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시간 동안만 같은 응답을 돌려주는 재전송 안전장치예요. 보관 수단이 아니고요 (보관은 데이터 취급 문서를 봐주세요).

#

오류

4xx / 5xx 오류는 다 같은 envelope 으로 돌려드려요. requestId 는 문의하실 때 단서가 돼요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "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_error

HTTP 상태

200
정상이에요
400
요청 형식이 잘못됐거나 검증에서 걸렸어요. 이미지 URL 을 받아오지 못하거나 base64 가 깨진 경우도 같은 400 이에요 (code: invalid_image, 과금되지 않아요). 다시 보내도 같은 답이라 입력을 고쳐주세요
401
API 키가 없거나 잘못됐어요
402
잔액이 모자라요. /upload 배치는 details.requested / processable / breakdown 까지 같이 알려드려요
403
이 키의 범위 밖 리소스예요 (예: 다른 키가 만든 job)
404
path 가 없거나 api.space-ocr.com 이 아닌 곳으로 호출했어요
413
본문이 28MB 를 넘거나 파일이 20MB 를 넘었어요. details.limitBytes 에 허용 크기, details.receivedBytes 에 받은 크기가 담겨요 (Content-Length 가 없으면 limitBytes 만 나가요). 32MiB 를 넘는 요청은 인프라 쪽에서 끊기므로 그 413 만은 JSON 이 아니라 HTML 이에요
429
레이트 제한이에요. Retry-After 헤더에 기다릴 초 수가 적혀 있어요
500
내부 오류예요
502
OCR 엔진 쪽 오류예요 (자동 환불해드려요). message 에 실제 사유가 담겨요. 이미지 입력 자체의 문제는 400 invalid_image 로 갈리니, 502 는 다시 시도해볼 만해요
504
동기 OCR 이 상한인 180초 안에 끝나지 않았어요 (과금되지 않아요). 원인은 화소 수보다 밀도인 경우가 대부분이라, 줄이기보다 1페이지 1이미지로 나누시거나 처리 시간에 여유가 있는 비동기 /upload 를 써주세요
#
POST/ocr/fieldsBearer₩100(부가세 포함)

구조화 OCR

이미지에서 이름 붙인 필드를 뽑아내요. fields 로 추출 스키마를 정하거나, autoFields 로 알아서 제안받을 수도 있어요.

동기 호출이라 응답이 올 때까지 연결을 유지하셔야 해요. 처리는 최장 180초이고 넘으면 504 ocr_engine_timeout 이에요 (과금되지 않아요). 실측으로는 한 장에 몇 초~수십 초에 들어오지만, 클라이언트 쪽 타임아웃은 여유 있게 잡아 주세요. 이 상한에 걸리는 건 대개 작은 글씨가 빽빽한 다페이지 서류이고 원인은 화소 수가 아니라 밀도예요 — 줄이면 오히려 못 읽으니 1페이지 1이미지로 나누시거나, 처리 시간에 여유가 있는 비동기 POST /upload 를 써주세요.

바디 파라미터

imagestringrequired

Base64 문자열 또는 이미지 URL. JSON 바디 상한은 28MB 이고 base64 는 파일의 약 1.33 배가 되니, 원본 이미지로는 20MB 쯤까지예요. 넘으면 413 (details.limitBytes / receivedBytes) 을 돌려드려요. 더 크면 URL 로 넘기거나 /upload (비동기, 파일당 20MB) 를 써주세요.

장변 4000px 를 넘는 이미지는 서버가 자동으로 축소해서 읽어요 — 좌표도 축소 후 페이지(data.image) 기준으로 돌아오니, 상한에 맞추려고 미리 화질을 낮춰 보내실 필요 없어요.

imageType"base64" | "url"required
image 의 타입을 직접 알려주세요. 구 이름 image_type 도 하위호환으로 동작해요 (deprecated).
fieldsarray<FieldSpec>optional

추출 스키마 배열이에요. autoFields 를 쓸 거면 생략해도 돼요. 값은 페이지에 적힌 읽기 그대로 돌아오고 요약하거나 바꿔 쓰지 않아요 — 그래야 좌표에 붙여서 검증할 수 있거든요.

다만 바이트 단위 복사본은 아니에요: values 는 모델이 읽은 문자열이고 문자 대조는 전각·괄호·공백을 접고 비교하기 때문에, (税抜)가 (税抜) 로 바뀌는 정도의 표기 차이는 통과해요. 정확 일치로 대조하실 거면 cells[path].evidence.printed_text (그 좌표에서 OCR 이 읽은 글자) 를 쓰세요.

namestringrequired
응답 JSON 의 키가 돼요.
type"string" | "array" | "object" | "number" | "integer" | "date"optional

기본값은 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 으로 받아서 그쪽 업무 규칙으로 해석하시는 게 좋아요.

descriptionstringoptional
그 값이 어디 있는지 알려주는 힌트예요 (예: "합계 오른쪽").
childrenarray<FieldSpec>optional
type 이 array / object 일 때의 하위 필드예요. 같은 FieldSpec 이 재귀로 들어가요. 명세행은 행 수를 세지 말고 type: "array" + children 으로 선언하는 걸 권해요 — 몇 행이 돌아올지는 페이지가 정해요. 자식의 좌표는 행 단위로 풀리기 때문에, 같은 열 제목 (수량·금액) 이 모든 행에 반복돼도 서로 헷갈리지 않아요. cells 의 키와 review.flagged[].path 는 items[0].amount 같은 첨자 경로가 돼요.
requiredbooleanoptional
true 인 항목이 빈 값으로 오거나 응답에서 아예 빠지면 review.flagged 에 reason "missing" 으로 기록돼요 — 돌아온 적 없는 값은 대조할 상대가 없어서, 문자 대조가 원리적으로 볼 수 없는 유일한 클래스거든요. 모델에게는 전달되지 않아 추출 동작은 그대로예요 (필수라고 알리면 인쇄되지 않은 값을 추론하지 말라는 지시와 부딪혀요). 없는 게 정상인 항목까지 켜면 신호가 묻히니, 늘 인쇄되는 값에만 켜주세요.
labelstring | string[]optional

값 옆에 인쇄된 라벨이에요 (예: "합계"). 같은 값이 페이지에 여러 번 찍혀 있을 때 좌표를 그 라벨 옆 등장에 앵커해줘요. 라벨이 페이지에 정확히 1회 인쇄됐을 때만 작동하고, 못 찾으면 기존 탐색으로 돌아가면서 그 사실이 review.notes 에 issue: "label_unresolved" 로 실려요 (값은 돌아오니까 이 고지가 없으면 선언이 안 듣는 걸 알 수가 없거든요). "消費税(8%)"・"10%対象 小計" 처럼 여러 단어에 걸친 표기도 그대로 쓰실 수 있어요. required 처럼 모델에게는 전달되지 않아요 — 추출 텍스트는 그대로고 좌표 앵커만 바뀌어요. 후보가 여러 개면 배열로 주세요 (예: ["발행일", "발행년월일"]).

듣는 건 최상위의 string / number / integer / date 필드뿐이에요. array・object 본체와 그 children 에 준 label 은 읽히지 않고 무시돼요 (페이지 전체에 한 번뿐인 라벨은 반복되는 행 중 어느 것을 가리키는지 원리적으로 말할 수 없거든요). 명세표의 "수량"·"금액" 처럼 열 제목이 모든 행에 공유되는 경우엔 label 이 필요 없어요 — children 의 좌표는 행 단위로 풀려요. 행 안에서 위치를 알려주고 싶으면 description 을 써주세요 (예: "단가 오른쪽").

nearstring | string[] | { terms, match }optional

값 옆에 인쇄돼 있어야 할 어휘예요 (예: 수신처 회사명이면 ["御中", "様"], 발행원이면 ["登録番号", "〒", "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배예요.

not_nearstring | string[] | { terms, match }optional

near 의 거울이에요 — 값 옆에 있으면 안 되는 어휘를 선언해요 (수신처 회사명에 ["登録番号", "TEL", "〒"]). 값이 그중 어느 것의 이웃에 있으면 reason "near_conflict" 가 서고, 옆에 있던 어휘와 거리가 cells[path].evidence.not_near 에 실려요. 형태도 match 지정도 near 와 같아요 (v85).

왜 둘 다 필요한가: near 는 식별 표식이 인쇄돼 있을 때만 말할 수 있어요. 그런데 당사자가 실제로 뒤바뀌는 건 수신처 행이 없는 사무용 폼이고, 거기엔 御中 이 아예 안 찍혀서 near 는 보류밖에 못 해요. 반면 발행원 블록은 무언가를 반드시 인쇄해요 (登録番号 / TEL / 〒). 그래서 닿는 말은 부정형이 돼요 — 발행원 블록 안에 앉아 있는 수신처는 발행원이에요.

어휘가 인쇄돼 있지 않은 건 위반이 아니라서, near 와 달리 보류하지 않고 그냥 통과해요 (review.notes 에도 안 실려요). 모델에게는 전달되지 않아요.

patternstring | string[]optional
정규화된 값이 만족해야 하는 정규식이에요. JSON Schema 와 같은 부분 일치라서 값 전체를 보려면 ^…$ 를 붙여주세요. 배열로 주면 "하나라도 맞으면 통과" 예요. string 타입 전용이고요. 대조는 전각을 반각으로 접은 값에 대해 하기 때문에, 페이지가 전각으로 인쇄돼 있어도 평범한 ASCII 패턴이 통해요 (T12… 는 T12… 로 대조). 어기면 reason "pattern_mismatch" 예요. 모델에게는 전달되지 않아요 — 형태를 알려주면 그 형태의 값을 만들어 버리거든요.
min / maxnumberoptional
number / integer 타입 값의 범위예요 (양끝 포함). 정규화된 수치에 대해 판정하고, 벗어나면 reason "out_of_range" 가 붙어요.
enumstring[]optional
업무측이 이미 갖고 있는 값의 집합을 API 에 건네는 구멍이에요 — 거래처 마스터의 회사명 목록, 품목 마스터, 단위 목록 (袋・本・個) 같은 것들요. 정규화된 값이 그 집합에 없으면 reason "pattern_mismatch" 가 서요. 아울러 두 엔진이 같은 오독에 합의해 버리는 클래스 (冊 을 申 으로 읽는 등) 에 듣는 유일한 수단이기도 해요 — 글자끼리 맞춰보는 검증은 양쪽이 같은 실수를 하면 구조적으로 아무 말도 못 하거든요. 다만 집합에 든 정당한 값끼리 (등록된 다른 거래처를 반환한 경우) 는 못 갈라요 — 그 자리는 near 가 맡아요.
review"normal" | "off"optional
"off" 로 하면 그 필드에 대해 엔진이 추정한 검토 사유 (text_mismatch・low_ratio・ambiguous_occurrence 등) 를 내지 않아요. 증거는 전부 남고 판정만 보류해요. 품명이나 비고 같은 자유 기술 열에 걸어두면, 등록번호나 합계에 선 표시가 읽히게 돼요. 선언한 규칙 (required 의 missing, pattern・min/max・enum 위반) 은 못 꺼요 — 직접 쓴 규칙이 직접 쓴 다른 키로 취소되면 위험하니까요.
autoFieldsbooleanoptional
true 면 fields 를 안 줘도 LLM 이 스키마를 알아서 제안해요. 구 이름 auto_fields 도 하위호환으로 동작해요 (deprecated).
promptstringoptional
자유 기술 지시예요 (선택).
요청
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
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": "합계" }
    ]
  }'

응답 필드

status"success"
성공이면 항상 "success" 예요. 오류는 HTTP 4xx/5xx 와 공통 오류 envelope(Errors 섹션)으로 가고, 이 바디로는 안 와요.
data.valuesobject

요청 스키마 그대로의 순수 사용자 데이터예요. 예약 키가 안 섞여서 그대로 DB 에 넣을 수 있어요. 값은 모델이 페이지에서 읽은 문자열이고 문자 대조로 인쇄물과 맞대어 둔 것이에요 — 바이트 단위 복사본은 아니라서, 정확 일치로 대조하실 거면 cells[path].evidence.printed_text 를 쓰세요.

값에는 반각 ¥(U+00A5) 같은 문자가 그대로 실려요. cp932 / Shift_JIS 로 인코딩하면 (CSV 출력 포함) 그 한 글자만으로 예외가 날 수 있으니 UTF-8 그대로 다뤄 주세요.

data.cellsmap<path, Cell>
path 를 키로 쓰는 flat 좌표·검증 맵이에요. 키가 review.flagged[].path 와 같은 문법(items[0].price)이라, flagged 의 path 로 바로 O(1) 조회돼요. items[0] 같은 행 path 는 행 전체 union box 예요.
box{ xmin, ymin, xmax, ymax }
축 평행 사각형이에요. 0~1000 정규화예요 (data.image 로 픽셀 환산). 정렬이나 영역 판정처럼 축으로 계산할 때 쓰기 좋아요. 기울어진 사진에서는 글자보다 넓게 잡히기도 하고, 그 정도가 지나치면 reason overwide_box 가 서요.
quad[{ x, y } × 4]

기울어진 스캔을 따라가는 4점이에요. box 와 항상 둘 다 붙어요. 화면에 테두리를 그릴 땐 이쪽을 쓰세요. 기준은 보내신 파일이 아니라 data.image 가 말하는 읽은 뒤의 페이지예요 — EXIF 로 옆으로 누운 사진은 정립시킨 다음 읽기 때문에 width 와 height 가 뒤바뀔 수 있어요 (4000×3000 으로 보내고 3000×4000 이 돌아오는 식). 픽셀 환산도 data.image 로 해주세요.

기울기 보정(deskew)은 하지 않아요 — 페이지를 돌려서 좌표를 다시 만드는 일은 없고, 돌아오는 좌표는 기울어진 채 읽은 입력 이미지의 좌표계예요. 기울어진 사진에서는 quad 가 그 기울기를 따라가요.

verifiedboolean | null

이 셀의 판정이에요. review 의 거울이라 둘이 어긋나지 않아요 — false 는 review 에 이유가 들어 있을 때(선언하신 규칙 위반을 포함해 이유 종류를 가리지 않아요), true 는 아무것도 안 섰고 대조가 실제로 돌았을 때, null 은 아무것도 안 섰지만 대조할 것도 없었을 때 (행 union 같은 기하 전용 항목) 예요. 게이트로는 이 하나면 충분해요.

문자 대조 자체(값이 이 좌표의 OCR 원문과 일치했는지, 두 독립 엔진의 합의)는 evidence.text_match 에 있어요. 그 불일치는 원래부터 전용 사유 text_mismatch 를 갖고 있어서 판정에서 잃는 게 없어요.

true 라고 해서 "요청하신 의미의 값" 이라는 뜻은 아니에요. 모델이 페이지의 다른 곳(근처의 보조 제목 같은)을 집어 왔다면 좌표는 그 집어 온 글자에 붙고 대조도 일치하니, 아무것도 안 서면 true 로 돌아와요. 좌표가 답하는 건 "이 값이 어디서 왔는가" 이지 "이게 맞는 항목인가" 가 아니에요 — 그건 label / near / enum 이 맡아요.

review{ reasons } | null

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 는 함께 내보내지 않아요 — 같은 사실을 두 어휘로 말하는 셈이라서요.

화면을 만드실 땐 여기 나열된 전 종(그리고 이후 추가될 수 있는 코드)에 표시를 할당하고, 한 셀에 여러 사유가 동시에 서는 배열을 전제로 그리세요 — 일부만 대응하면 미대응 코드에서 화면이 깨져요. 모르는 코드는 제네릭 "검토 필요" 로 떨어뜨리는 게 안전해요.

evidenceobject
판정의 원자료예요 — text_match (문자 대조 그 자체: 값이 이 좌표의 OCR 원문과 일치했는지. 대조가 돌았을 때만 키가 있고, 안 돌았으면 verified 도 null 이에요. 판정이 false 인데 text_match: true 일 수 있어요 — 글자는 맞았고 선언하신 규칙 쪽이 잡은 경우예요) / source (좌표 산출 경로: vision_symbol_match / token_id …) / match_ratio (문자 대조 일치율) / printed_text (그 좌표에서 OCR 이 읽은 글자 그대로예요. values 는 모델이 쓴 문자열이고 위의 문자 대조는 전각·괄호·공백을 접고 비교하기 때문에, 모델이 다시 쓴 표기도 통과해요 — 마스터 대조처럼 정확 일치가 필요하시면 이쪽을 쓰세요. values 를 대체하는 값은 아니에요: OCR 쪽에도 오독이 있고, 그래서 둘을 맞대어 보는 거예요. 글리프만 이어 붙여요 — 단어 사이 띄어쓰기는 복원되지 않으니 공백은 무시하고 비교하세요. 여기에 공백이 없다고 해서 페이지에 공백이 없다는 뜻은 아니에요) / near (near 를 선언한 항목이 near_mismatch・near_ambiguous 로 섰을 때만 있어요: occurrences = 이 값이 지면에 인쇄된 횟수, satisfied = 그중 선언 어휘 근처에 있는 것, anchored = 좌표가 붙은 쪽이 그 안에 드는지, nearest_term 과 nearest = 가장 가까운 선언 어휘와 거기까지의 거리. 거리는 허용된 창의 배수라 1 이하면 통과했을 거리예요 — 창이 가로 6・세로 3 으로 비등방이라 절대 거리 하나로는 임계와 비교할 수 없거든요) / not_near (near_conflict 일 때만: matched = 옆에 있던 어휘, distance = 같은 배수) / ocr_confidence (매칭 글리프에 대한 OCR 자신의 신뢰도 최솟값, 없으면 키 없음) / crop_verified (크롭 재검증 결과, 실행됐을 때만) / multiline (값이 줄바꿈돼서 box 가 그 여러 줄의 union 이라는 표시예요. true 일 때만 키가 있어요).
normalized{ value, type, method, error? }

스칼라 타입 (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 그대로라 건수 집계는 안 바뀌어요.

data.reviewobject
문서 한 장 분의 검증 요약이에요. 필드별 판정은 cells 쪽이고, 여기는 집계와 검토 목록이에요.
unit"field"
집계 단위예요.
declaredinteger
빈 값·안 돌아온 required 까지 센 전체 슬롯이에요 (고정 분모).
returnedinteger
비어있지 않은 값 수예요.
boxedinteger
좌표가 붙은 셀 수예요.
verifiedinteger
verified: true 인 셀 수 = 검토에 안 올라갔고 대조가 돌아간 셀이에요. 플래그가 선 셀은 여기 안 들어와요 (그 수는 flagged.length 예요).
flagged[{ path, reasons }]
검토 목록이에요. 검토 건수는 이 배열의 길이 그 자체(별도 카운터 없음), path 는 cells 키와 같은 문법이에요. 값은 있는데 좌표가 없는 필드(nobox)와 안 돌아온 required(missing)는 셀이 없어서 여기에만 나와요. missing 은 required 를 선언한 필드에만 서요 — 선언 안 한 필드의 누락에는 플래그가 안 서요. reasons 는 어긴 규칙 전부를 랭킹 순으로 담은 배열이고 0번이 대표예요 (길이가 1이어도 항상 배열).
by_reasonobject
사유별 내역이에요 (예: { "pattern_mismatch": 1, "text_mismatch": 1 }). 한 셀이 선언한 규칙과 엔진의 의심을 동시에 어길 수 있어서, reasons 에 실린 사유를 전부 세요. 그래서 합계는 flagged 건수 이상이에요 (검토 건수 자체는 그대로 flagged.length).
notesarray

선언이 그대로 실행되지 못했을 때만 붙는 고지예요. 항목마다 path / issue / description 을 갖고, issue 로 분기하시면 돼요.

issue: "type_coerced" 는 이 API 가 지원하지 않는 타입을 선언하신 경우예요 (declared_type / applied_type 도 붙어요). 지원 타입은 string / number / integer / date / array / object 이고, 스칼라 타입은 조용히 처리돼서 해석된 값이 normalized 로 돌아와요.

issue: "label_unresolved" 는 선언하신 label 이 아무것도 앵커하지 못한 경우예요 (인쇄되지 않음・두 번 이상 있음・옆에 확신할 값이 없음). 값 자체는 기존 탐색으로 돌아오기 때문에, 이 고지가 없으면 선언이 안 듣고 있다는 걸 알 수가 없어요.

data.normalizedobject
스칼라 타입(number / integer / date, 또는 pattern・enum 을 준 string)을 선언한 필드가 있을 때만 붙어요. values 와 완전히 같은 모양의 트리이고 리프만 그 타입으로 해석한 값이에요 (normalized.items[0].qty 가 values.items[0].qty 옆에 놓여요). 선언한 리프만 있는 성긴 트리라, 해석하지 못한 리프는 null 이고 이유는 cells[path].normalized.error 에 있어요. 해석은 결정론적이라 같은 페이지면 매번 같은 값이 나와요. values 쪽은 그대로 두고 여기만 더해져요 — 좌표와 검증이 붙어 있는 건 values 쪽이에요.
data.image{ width, height }
읽은 페이지의 픽셀 크기예요. 모든 좌표가 이 기준이에요. 0~1000 정규화 좌표를 픽셀로 되돌릴 때 써요 (pixel_x = box.xmin / 1000 × width). EXIF 정립과 (필요한 경우) 축소를 거친 뒤의 값이라 보내신 파일의 width / height 와 다를 수 있어요.
응답
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
56
57
58
59
60
61
{
  "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 }
  }
}
#
POST/ocr/markdownBearer₩100(부가세 포함)

마크다운 변환

레이아웃을 살린 채로 이미지를 마크다운으로 바꿔요. 제목·문단·목록·표가 요소로 나오고, 요소마다 좌표가 붙어요.

동기 호출이라 응답이 올 때까지 연결을 유지하셔야 해요. 처리는 최장 180초이고 넘으면 504 ocr_engine_timeout 이에요 (과금되지 않아요). 실측으로는 한 장에 몇 초~수십 초에 들어오지만, 클라이언트 쪽 타임아웃은 여유 있게 잡아 주세요. 이 상한에 걸리는 건 대개 작은 글씨가 빽빽한 다페이지 서류이고 원인은 화소 수가 아니라 밀도예요 — 줄이면 오히려 못 읽으니 1페이지 1이미지로 나누시거나, 처리 시간에 여유가 있는 비동기 POST /upload 를 써주세요.

바디 파라미터

imagestringrequired

Base64 문자열 또는 이미지 URL. JSON 바디 상한은 28MB 이고 base64 는 파일의 약 1.33 배가 되니, 원본 이미지로는 20MB 쯤까지예요. 넘으면 413 (details.limitBytes / receivedBytes) 을 돌려드려요. 더 크면 URL 로 넘기거나 /upload (비동기, 파일당 20MB) 를 써주세요.

장변 4000px 를 넘는 이미지는 서버가 자동으로 축소해서 읽어요 — 좌표도 축소 후 페이지(data.image) 기준으로 돌아오니, 상한에 맞추려고 미리 화질을 낮춰 보내실 필요 없어요.

imageType"base64" | "url"required
image 의 타입을 직접 알려주세요. 구 이름 image_type 도 하위호환으로 동작해요 (deprecated).
promptstringoptional
레이아웃 해석에 대한 자유 기술 추가 지시예요 (선택).
includeElementsbooleanoptional
기본값 true — values.elements(내용)와 cells(요소별 좌표·검증)를 함께 돌려줘요. false 로 주면 조립된 마크다운 문자열만 나와요.
요청
1
2
3
4
5
6
7
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"
  }'

응답 필드

status"success"
성공이면 항상 "success" 예요. 오류는 HTTP 4xx/5xx 와 공통 오류 envelope(Errors 섹션)으로 가고, 이 바디로는 안 와요.
data.values.markdownstring
조립된 마크다운 문자열이에요.
data.values.elementsarray<Element>
내용만 담은 요소 배열이에요. 좌표·검증 플래그는 cells 쪽으로 분리돼 있어요. 어떤 요소도 청구하지 않은 OCR 토큰은 paragraph(evidence.source: unclaimed_tokens)로 말미에 회수돼, 흘린 문단이 사라지지 않아요.
type"heading" | "paragraph" | "list_item" | "blockquote" | "code_block" | "thematic_break" | "table"
요소의 종류예요.
textstring
요소의 본문이에요 (table 제외).
levelinteger
heading 의 제목 레벨이에요.
rowsinteger
table 의 행 수예요.
colsinteger
table 의 열 수예요.
cells[{ row, col, header, text }]
table 의 셀 배열이에요.
data.cellsmap<path, Cell>
path 를 키로 쓰는 flat 좌표·검증 맵이에요 — elements[3] 이 요소, elements[2].cells[1] 이 표의 셀. review.flagged[].path 와 같은 문법이라 flagged 에서 바로 찾아가요. includeElements: false 면 안 붙어요.
box{ xmin, ymin, xmax, ymax }
축 평행 사각형이에요. 0~1000 정규화예요 (data.image 로 픽셀 환산). 정렬이나 영역 판정처럼 축으로 계산할 때 쓰기 좋아요. 기울어진 사진에서는 글자보다 넓게 잡히기도 하고, 그 정도가 지나치면 reason overwide_box 가 서요.
quad[{ x, y } × 4]

기울어진 스캔을 따라가는 4점이에요. box 와 항상 둘 다 붙어요. 화면에 테두리를 그릴 땐 이쪽을 쓰세요. 기준은 보내신 파일이 아니라 data.image 가 말하는 읽은 뒤의 페이지예요 — EXIF 로 옆으로 누운 사진은 정립시킨 다음 읽기 때문에 width 와 height 가 뒤바뀔 수 있어요 (4000×3000 으로 보내고 3000×4000 이 돌아오는 식). 픽셀 환산도 data.image 로 해주세요.

기울기 보정(deskew)은 하지 않아요 — 페이지를 돌려서 좌표를 다시 만드는 일은 없고, 돌아오는 좌표는 기울어진 채 읽은 입력 이미지의 좌표계예요. 기울어진 사진에서는 quad 가 그 기울기를 따라가요.

verifiedboolean | null

이 셀의 판정이에요. review 의 거울이라 둘이 어긋나지 않아요 — false 는 review 에 이유가 들어 있을 때(선언하신 규칙 위반을 포함해 이유 종류를 가리지 않아요), true 는 아무것도 안 섰고 대조가 실제로 돌았을 때, null 은 아무것도 안 섰지만 대조할 것도 없었을 때 (표 요소 자체 — 셀 각각은 검증돼요) 예요. 게이트로는 이 하나면 충분해요.

문자 대조 자체(값이 이 좌표의 OCR 원문과 일치했는지, 두 독립 엔진의 합의)는 evidence.text_match 에 있어요. 그 불일치는 원래부터 전용 사유 text_mismatch 를 갖고 있어서 판정에서 잃는 게 없어요.

true 라고 해서 "요청하신 의미의 값" 이라는 뜻은 아니에요. 모델이 페이지의 다른 곳(근처의 보조 제목 같은)을 집어 왔다면 좌표는 그 집어 온 글자에 붙고 대조도 일치하니, 아무것도 안 서면 true 로 돌아와요. 좌표가 답하는 건 "이 값이 어디서 왔는가" 이지 "이게 맞는 항목인가" 가 아니에요 — 그건 label / near / enum 이 맡아요.

review{ reasons } | null

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 는 함께 내보내지 않아요 — 같은 사실을 두 어휘로 말하는 셈이라서요.

화면을 만드실 땐 여기 나열된 전 종(그리고 이후 추가될 수 있는 코드)에 표시를 할당하고, 한 셀에 여러 사유가 동시에 서는 배열을 전제로 그리세요 — 일부만 대응하면 미대응 코드에서 화면이 깨져요. 모르는 코드는 제네릭 "검토 필요" 로 떨어뜨리는 게 안전해요.

evidenceobject
판정의 원자료예요 — text_match (문자 대조 그 자체: 값이 이 좌표의 OCR 원문과 일치했는지. 대조가 돌았을 때만 키가 있고, 안 돌았으면 verified 도 null 이에요. 판정이 false 인데 text_match: true 일 수 있어요 — 글자는 맞았고 선언하신 규칙 쪽이 잡은 경우예요) / source (좌표 산출 경로: token_id / char_matcher_fallback / unclaimed_tokens) / match_ratio (문자 대조 일치율) / ocr_confidence (매칭 글리프에 대한 OCR 자신의 신뢰도 최솟값, 없으면 키 없음) / crop_verified (크롭 재검증 결과, 실행됐을 때만) / multiline (값이 줄바꿈돼서 box 가 그 여러 줄의 union 이라는 표시예요. true 일 때만 키가 있어요).
data.reviewobject
문서 한 장 분의 검증 요약이에요. 요소별 판정은 cells 쪽이고, 여기는 집계와 검토 목록이에요.
unit"element"
집계 단위예요.
totalinteger
셀 단위 총 수예요 (표는 셀 각각을 세요).
boxedinteger
좌표가 붙은 셀 수예요.
verifiedinteger
verified: true 인 셀 수 = 검토에 안 올라갔고 대조가 돌아간 셀이에요. 플래그가 선 셀은 여기 안 들어와요 (그 수는 flagged.length 예요).
flagged[{ path, reasons }]
검토 목록이에요. 검토 건수는 이 배열의 길이 그 자체예요 (별도 카운터 없음). path 는 cells 의 키와 같은 문법이라 그대로 조회돼요. reasons 는 어긴 규칙 전부를 랭킹 순으로 담은 배열이고 0번이 대표예요 (길이가 1이어도 항상 배열).
by_reasonobject
사유별 내역이에요 (예: { "pattern_mismatch": 1, "text_mismatch": 1 }). 한 셀이 선언한 규칙과 엔진의 의심을 동시에 어길 수 있어서, reasons 에 실린 사유를 전부 세요. 그래서 합계는 flagged 건수 이상이에요 (검토 건수 자체는 그대로 flagged.length).
coverageobject
recovered_blocks / vision_tokens / tokens_claimed / token_coverage — 페이지를 얼마나 건졌는지예요.
data.image{ width, height }
읽은 페이지의 픽셀 크기예요. 모든 좌표가 이 기준이에요. 0~1000 정규화 좌표를 픽셀로 되돌릴 때 써요 (pixel_x = box.xmin / 1000 × width). EXIF 정립과 (필요한 경우) 축소를 거친 뒤의 값이라 보내신 파일의 width / height 와 다를 수 있어요.
응답
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": {
      "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 }
  }
}
#
POST/ocr/textBearer₩100(부가세 포함)

원문 텍스트 OCR

스키마도 마크다운 문법도 없이 문서의 글자만 전부 돌려줘요. 모델이 이미지를 보고 진짜 읽기순서로 블록을 정렬하기 때문에, 다단 조판이나 기울어진 스캔에서도 문장이 뒤섞이지 않아요.

동기 호출이라 응답이 올 때까지 연결을 유지하셔야 해요. 처리는 최장 180초이고 넘으면 504 ocr_engine_timeout 이에요 (과금되지 않아요). 실측으로는 한 장에 몇 초~수십 초에 들어오지만, 클라이언트 쪽 타임아웃은 여유 있게 잡아 주세요. 이 상한에 걸리는 건 대개 작은 글씨가 빽빽한 다페이지 서류이고 원인은 화소 수가 아니라 밀도예요 — 줄이면 오히려 못 읽으니 1페이지 1이미지로 나누시거나, 처리 시간에 여유가 있는 비동기 POST /upload 를 써주세요.

바디 파라미터

imagestringrequired

Base64 문자열 또는 이미지 URL. JSON 바디 상한은 28MB 이고 base64 는 파일의 약 1.33 배가 되니, 원본 이미지로는 20MB 쯤까지예요. 넘으면 413 (details.limitBytes / receivedBytes) 을 돌려드려요. 더 크면 URL 로 넘기거나 /upload (비동기, 파일당 20MB) 를 써주세요.

장변 4000px 를 넘는 이미지는 서버가 자동으로 축소해서 읽어요 — 좌표도 축소 후 페이지(data.image) 기준으로 돌아오니, 상한에 맞추려고 미리 화질을 낮춰 보내실 필요 없어요.

imageType"base64" | "url"required
image 의 타입을 직접 알려주세요. 구 이름 image_type 도 하위호환으로 동작해요 (deprecated).
useLlmbooleanoptional
기본값 true — 읽기순서로 정렬하고 줄바꿈으로 끊긴 단어를 다시 붙여요. false 로 주면 Vision 전용 전사예요 (즉시 응답·LLM 비용 0, 대신 raw OCR 순서).
includeBlocksbooleanoptional
true 면 values.blocks(내용)와 cells(블록별 box / quad / verified / review)도 함께 돌려줘요. 기본값은 false 예요.
promptstringoptional
전사 프롬프트를 바꿔 넣을 수 있어요 (선택).
요청
1
2
3
4
5
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
  }'

응답 필드

status"success"
성공이면 항상 "success" 예요. 오류는 HTTP 4xx/5xx 와 공통 오류 envelope(Errors 섹션)으로 가고, 이 바디로는 안 와요.
data.values.textstring
전문이에요. 블록을 읽기순서로 결합한 문자열이에요.
data.values.blocks[{ text }]
내용만 담은 블록 배열이에요 (includeBlocks: true 일 때). 좌표·검증 플래그는 cells 쪽이에요. 어떤 블록도 청구하지 않은 OCR 토큰은 회수 블록(evidence.source: unclaimed_tokens)으로 말미에 붙어, 흘린 문단이 조용히 사라지지 않아요.
data.cellsmap<path, Cell>
path 를 키로 쓰는 flat 좌표·검증 맵이에요 (blocks[7]). review.flagged[].path 와 같은 문법이라 flagged 에서 바로 찾아가요. includeBlocks: true 일 때 붙어요.
box{ xmin, ymin, xmax, ymax }
축 평행 사각형이에요. 0~1000 정규화예요 (data.image 로 픽셀 환산). 정렬이나 영역 판정처럼 축으로 계산할 때 쓰기 좋아요. 기울어진 사진에서는 글자보다 넓게 잡히기도 하고, 그 정도가 지나치면 reason overwide_box 가 서요.
quad[{ x, y } × 4]

기울어진 스캔을 따라가는 4점이에요. box 와 항상 둘 다 붙어요. 화면에 테두리를 그릴 땐 이쪽을 쓰세요. 기준은 보내신 파일이 아니라 data.image 가 말하는 읽은 뒤의 페이지예요 — EXIF 로 옆으로 누운 사진은 정립시킨 다음 읽기 때문에 width 와 height 가 뒤바뀔 수 있어요 (4000×3000 으로 보내고 3000×4000 이 돌아오는 식). 픽셀 환산도 data.image 로 해주세요.

기울기 보정(deskew)은 하지 않아요 — 페이지를 돌려서 좌표를 다시 만드는 일은 없고, 돌아오는 좌표는 기울어진 채 읽은 입력 이미지의 좌표계예요. 기울어진 사진에서는 quad 가 그 기울기를 따라가요.

verifiedboolean | null

이 셀의 판정이에요. review 의 거울이라 둘이 어긋나지 않아요 — false 는 review 에 이유가 들어 있을 때(선언하신 규칙 위반을 포함해 이유 종류를 가리지 않아요), true 는 아무것도 안 섰고 대조가 실제로 돌았을 때, null 은 아무것도 안 섰지만 대조할 것도 없었을 때 (기하 전용 항목) 예요. 게이트로는 이 하나면 충분해요.

문자 대조 자체(값이 이 좌표의 OCR 원문과 일치했는지, 두 독립 엔진의 합의)는 evidence.text_match 에 있어요. 그 불일치는 원래부터 전용 사유 text_mismatch 를 갖고 있어서 판정에서 잃는 게 없어요.

true 라고 해서 "요청하신 의미의 값" 이라는 뜻은 아니에요. 모델이 페이지의 다른 곳(근처의 보조 제목 같은)을 집어 왔다면 좌표는 그 집어 온 글자에 붙고 대조도 일치하니, 아무것도 안 서면 true 로 돌아와요. 좌표가 답하는 건 "이 값이 어디서 왔는가" 이지 "이게 맞는 항목인가" 가 아니에요 — 그건 label / near / enum 이 맡아요.

review{ reasons } | null

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 는 함께 내보내지 않아요 — 같은 사실을 두 어휘로 말하는 셈이라서요.

화면을 만드실 땐 여기 나열된 전 종(그리고 이후 추가될 수 있는 코드)에 표시를 할당하고, 한 셀에 여러 사유가 동시에 서는 배열을 전제로 그리세요 — 일부만 대응하면 미대응 코드에서 화면이 깨져요. 모르는 코드는 제네릭 "검토 필요" 로 떨어뜨리는 게 안전해요.

evidenceobject
판정의 원자료예요 — text_match (문자 대조 그 자체: 값이 이 좌표의 OCR 원문과 일치했는지. 대조가 돌았을 때만 키가 있고, 안 돌았으면 verified 도 null 이에요. 판정이 false 인데 text_match: true 일 수 있어요 — 글자는 맞았고 선언하신 규칙 쪽이 잡은 경우예요) / source (좌표 산출 경로: token_id / char_matcher_fallback / unclaimed_tokens / vision_paragraph) / match_ratio (문자 대조 일치율) / ocr_confidence (매칭 글리프에 대한 OCR 자신의 신뢰도 최솟값, 없으면 키 없음) / crop_verified (크롭 재검증 결과, 실행됐을 때만) / multiline (값이 줄바꿈돼서 box 가 그 여러 줄의 union 이라는 표시예요. true 일 때만 키가 있어요).
data.reviewobject
문서 한 장 분의 검증 요약이에요. Vision 전용 경로(useLlm: false)에서도 항상 실려요.
unit"block"
집계 단위예요.
totalinteger
블록의 총 수예요.
boxedinteger
좌표가 붙은 셀 수예요.
verifiedinteger
verified: true 인 셀 수 = 검토에 안 올라갔고 대조가 돌아간 셀이에요. 플래그가 선 셀은 여기 안 들어와요 (그 수는 flagged.length 예요).
flagged[{ path, reasons }]
검토 목록이에요. 검토 건수는 이 배열의 길이 그 자체예요 (별도 카운터 없음). path 는 cells 의 키와 같은 문법이라 그대로 조회돼요. reasons 는 어긴 규칙 전부를 랭킹 순으로 담은 배열이고 0번이 대표예요 (길이가 1이어도 항상 배열).
by_reasonobject
사유별 내역이에요 (예: { "pattern_mismatch": 1, "text_mismatch": 1 }). 한 셀이 선언한 규칙과 엔진의 의심을 동시에 어길 수 있어서, reasons 에 실린 사유를 전부 세요. 그래서 합계는 flagged 건수 이상이에요 (검토 건수 자체는 그대로 flagged.length).
coverageobject
recovered_blocks / vision_tokens / tokens_claimed / token_coverage — 페이지를 얼마나 건졌는지예요 (useLlm: true 인 LLM 경로에서만).
data.image{ width, height }
읽은 페이지의 픽셀 크기예요. 모든 좌표가 이 기준이에요. 0~1000 정규화 좌표를 픽셀로 되돌릴 때 써요 (pixel_x = box.xmin / 1000 × width). EXIF 정립과 (필요한 경우) 축소를 거친 뒤의 값이라 보내신 파일의 width / height 와 다를 수 있어요.
data.source"llm" | "vision"
"llm" 은 읽기순서를 정렬한 경로, "vision" 은 LLM 실패 시 자동 폴백이에요 (사유는 warning 에 실려요).
응답
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
{
  "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"
  }
}
#
GET/spaceBearer

트리 조회

MySpace 의 폴더/시트/메모를 한눈에 보여드려요. path 와 depth 로 범위를 좁힐 수 있어요.

쿼리 파라미터

pathstringoptional
"/" 로 구분해요. 폴더는 이름, 시트는 이름 (부모 안에서 유일) 또는 /create 가 알려준 uniqueKey 로 가리킬 수 있어요. 기본값은 "/" 예요.
depthinteger 1..10optional
재귀 깊이예요. 기본은 1.
요청
1
2
curl https://api.space-ocr.com/space?path=/&depth=1 \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

pathstring
요청에서 지정한 기준 path 예요.
depthinteger
적용된 재귀 깊이예요.
itemsarray<Item>
하위 아이템 목록이에요.
pathstring
아이템의 전체 path 예요. /view・/upload・/remove 에 그대로 넘길 수 있어요.
namestring
표시 이름이에요.
type"folder" | "sheet" | "doc" | "memo" | "img"
아이템의 종류예요. doc 은 문서 묶음(.md / .txt)이에요.
uniqueKeystring
이름과 무관한 안정 키예요 (folder 제외). path 세그먼트로도 쓸 수 있어요.
createdAtinteger (epoch ms)
생성 시각이에요.
extensionsobject | null
부가 메타데이터예요 (없으면 null, folder 제외).
응답
1
2
3
4
5
6
7
8
9
10
11
{
  "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 도 같이 와요.
#
GET/viewBearer

내용 조회

폴더/시트/문서 묶음/메모/이미지 모든 종류의 내용을 돌려드려요. 문서 묶음은 pages 배열, 시트는 rows 배열로 나와요. 쿼리(where / sort / select / limit / offset / boxes)는 시트에서만 동작해요 — 다른 종류에 붙이면 무시되고 전체가 그대로 나와요.

시트 행은 업로드 시각(createdAt) 오름차순으로 나와요. POST /edit・POST /remove 의 row: N 과 같은 순서라서, 응답의 N 번째 행이 곧 row: N 이에요.

쿼리 파라미터

pathstringrequired
대상 path 예요.
wherestring | string[]optional
【시트 전용】 행 필터예요. 예: total>=40000 / vendor~ABC. 반복해서 넣으면 AND 로 묶여요. 연산자는 = != > >= < <= ~ (~ 는 부분일치) 가 있어요. 시트 컬럼이랑 name / ocrStatus / createdAt 까지 대상이에요. 저장된 값은 모델이 페이지에서 읽은 문자열이라, 비교 전에 숫자로 바꿔 봐요: 전각을 반각으로 정규화하고 통화기호(¥ ¥ $ ₩ € £ 円 元 원 등)·자릿수 구분자·회계식 음수 표기((1,200) / △1,200 / 끝 하이픈)를 떼어 낸 뒤 순수한 십진수면 숫자로 비교하고, 아니면 문자열로 비교해요 (양쪽 다 숫자일 때만 숫자 비교). 날짜·전화번호·12% 같은 건 숫자로 안 읽혀요.
sortstring | string[]optional
【시트 전용】 정렬 기준이에요. 예: total:desc / -invoice_date. 여러 번 넣으면 tie-break 으로 쓰여요. where 와 같은 규칙으로 숫자화를 시도해서, 둘 다 숫자면 숫자 순, 아니면 문자열 순으로 정렬해요.
selectstringoptional
【시트 전용】 콤마로 구분한 반환 컬럼명이에요. 예: vendor,total.
limitinteger 1..500optional
【시트 전용】 한 번에 받을 행 수 상한이에요. 문서 묶음의 pages 에는 안 걸려요.
offsetintegeroptional
【시트 전용】 페이징용 건너뛰기 수예요. 응답의 nextOffset 이랑 같이 써요.
boxes"0" | "1" | "true" | "false"optional
【시트 전용】 0 / false 로 주면 행의 cells(좌표·검증 맵)를 뺀 가벼운 응답으로 와요. values / review / image 는 남아요. select= 는 values 키와, 첫 세그먼트가 일치하는 cells 경로에 적용돼요.
요청
1
2
3
4
5
6
7
8
# 다중 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"folder" | "sheet" | "doc" | "memo" | "img"
대상의 종류예요. 아래 필드들은 type 에 따라 붙는 게 달라요.
pathstring
대상 path 예요.
namestring
표시 이름이에요 (folder 제외).
columnsarray<ColumnSpec>
【시트】 이 시트의 컬럼 스키마예요 (POST /create 와 같은 모양).
totalinteger
【시트】 시트 전체 행 수 /【doc】 페이지 수예요.
matchedinteger
【시트】 where 를 통과한 행 수예요.
offset / limit / nextOffsetinteger | null
【시트】 페이징 상태예요. nextOffset 을 다음 offset 으로 넘겨요 (끝나면 null).
rowsarray<Row>
【시트】 행 배열이에요. createdAt 오름차순 — POST /edit・POST /remove 의 row: N 과 같은 순서예요.
rowKeystring
행의 안정 키예요. POST /edit 의 row 로 그대로 넘길 수 있어요.
namestring
원본 파일명이에요.
createdAtinteger (epoch ms)
업로드 시각 — 기본 행 순서의 근거예요.
imageUrlstring | null
원본 이미지 URL 이에요.
ocrStatus"pending" | "done" | "failed"
OCR 상태예요.
valuesobject | null
추출된 값이에요 (컬럼명이 키). POST /ocr/fields 의 data.values 와 같은 순수 사용자 데이터예요. OCR 전이면 null.
cellsmap<path, Cell>
POST /ocr/fields 와 같은 좌표·검증 맵이에요. boxes=0 이면 생략돼요.
reviewobject
POST /ocr/fields 와 같은 검증 요약이에요 (unit: "field").
image{ width, height }
읽은 페이지의 픽셀 크기예요 (좌표의 기준). 보내신 파일과 다를 수 있어요.
mode"markdown" | "text"
【doc】 묶음의 변환 모드예요.
pagesarray<Page>
【doc】 페이지 배열이에요 (업로드순). 각 페이지는 values(mode 에 따라 { markdown, elements } 또는 { text, blocks }) + cells + review + image — POST /ocr/markdown・/ocr/text 와 같은 v2 구조예요. OCR 전 페이지는 values: null 만 있어요.
pageKeystring
페이지의 안정 키예요.
namestring
원본 파일명이에요.
imageUrlstring | null
원본 이미지 URL 이에요.
ocrStatus"pending" | "done" | "failed"
OCR 상태예요.
valuesobject | null
mode=markdown 이면 { markdown, elements }, mode=text 면 { text, blocks } 예요.
cellsmap<path, Cell>
POST /ocr/markdown・/ocr/text 와 같은 좌표·검증 맵이에요 (elements[3] / blocks[7] …).
reviewobject
같은 엔드포인트와 같은 검증 요약이에요 (unit: "element" / "block").
image{ width, height }
읽은 페이지의 픽셀 크기예요 (좌표의 기준). 보내신 파일과 다를 수 있어요.
itemsarray
【folder】 자식 아이템 목록이에요 ({ path, name, type, uniqueKey? }).
textstring
【memo】 메모 본문이에요.
imageUrl / ocrStatusstring | null
【img】 원본 이미지 URL 과 OCR 상태예요.
응답
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// 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" }
#
POST/createBearer

생성

부모 폴더 아래에 folder / sheet / doc / memo 를 만들어요. 시트는 OCR 스키마(columns) 랑 prompt 를, doc(문서 묶음)은 mode 를 가져요.

바디 파라미터

pathstringrequired
부모 폴더의 path 예요.
type"folder" | "sheet" | "doc" | "memo"required
어떤 아이템을 만들지 정해요. doc 은 문서 묶음(.md / .txt) 이에요.
namestringrequired
표시할 이름이에요.
textstringoptional
메모 본문이에요 (type=memo 일 때만 써요).
columnsarray<ColumnSpec>optional
시트의 OCR 스키마예요 (type=sheet 용). 이후 이 시트에 올리는 사진은 매번 이 스키마대로 추출돼요.
idstringoptional
컬럼의 안정 ID 예요. 생략하면 자동으로 만들어 드려요.
namestringrequired
컬럼 이름이에요. 뽑아낸 값이 이 이름으로 행에 들어가요.
type"string" | "number" | "integer" | "date" | "array"required
값의 형태예요. 단일 값은 string, 명세행처럼 반복되는 줄은 array 로 두고 children 으로 줄 안의 항목을 선언해요. number / integer / date 를 선언하면 업로드마다 각 셀에 해석된 normalized 값이 붙고, 해석하지 못한 값에는 reason "type_mismatch" 가 서요 — 의미론은 /ocr/fields 의 FieldSpec.type 과 같고, 타입이 모델에게 전달되는 일은 없어요.
descriptionstringoptional
그 값이 어디 있는지 알려주는 힌트예요 (예: "합계 오른쪽").
childrenarray<ColumnSpec>optional
type 이 array 인 컬럼의 하위 필드예요. 명세행의 각 셀이 돼요.
requiredbooleanoptional
true 인 컬럼은 그 값이 비어서 오거나 읽히지 않았을 때 그 행의 review.flagged 에 reason "missing" 으로 나타나요 — 업로드할 때마다 적용되고 추출 동작은 그대로예요. 늘 인쇄되는 값에만 켜주세요.
labelstring | string[]optional
값 옆에 인쇄된 라벨이에요 (예: "합계"). 같은 값이 페이지에 여러 번 찍혀 있을 때 좌표를 그 라벨 옆 등장에 앵커해줘요 (v64). string 컬럼 전용이고 라벨이 페이지에 정확히 1회일 때만 작동하며, 모델에게는 전달되지 않아요 — 추출 텍스트는 그대로고 좌표만 바뀌어요. "消費税(8%)" 처럼 여러 단어에 걸친 표기도 쓰실 수 있어요.
nearstring | string[] | { terms, match }optional
값 옆에 인쇄돼 있어야 할 어휘예요 (예: 수신처면 ["御中", "様"]). 업로드마다 값의 모든 출현 을 보고 reason "near_mismatch" (어느 출현도 어휘 옆에 없음) 또는 "near_ambiguous" (옆에 있는 출현은 있는데 좌표가 붙은 건 다른 사본) 가 서고, 단어가 페이지 어디에도 없으면 판정을 보류하고 review.notes 에 issue: "near_unresolved" 로 실려요. { terms, match } 로 어휘가 붙는 자리 (boundary 기본 / suffix / prefix / standalone / anywhere) 도 지정하실 수 있어요 (v85). 의미론은 FieldSpec.near 와 같고, 모델에게는 전달되지 않아요.
not_nearstring | string[] | { terms, match }optional
near 의 거울이에요 — 값 옆에 있으면 안 되는 어휘예요 (수신처 컬럼에 ["登録番号", "TEL", "〒"]). 옆에 있으면 reason "near_conflict" 가 서요. 수신처 행이 없는 서식에서는 御中 이 안 찍혀서 near 가 보류밖에 못 하는데, 거기 닿는 게 이쪽이에요 (v85). 의미론은 FieldSpec.not_near 와 같고, 모델에게는 전달되지 않아요.
patternstring | string[]optional
값이 만족해야 하는 정규식이에요 (string 컬럼 전용, JSON Schema 와 같은 부분 일치 — 값 전체는 ^…$). 배열은 "하나라도 맞으면 통과" 예요. 대조는 전각을 반각으로 접은 값에 대해 하고, 어기면 reason "pattern_mismatch" 예요. 모델에게는 전달되지 않아요.
min / maxnumberoptional
number / integer 컬럼 값의 범위예요 (양끝 포함). 정규화된 수치에 대해 판정하고, 벗어나면 reason "out_of_range" 가 붙어요.
enumstring[]optional
허용하는 값의 집합이에요 (string 컬럼 전용) — 거래처 마스터의 회사명 목록, 단위 목록 (袋・本・個) 같은 것들요. 집합에 없는 값에는 reason "pattern_mismatch" 가 서요. 두 엔진이 같은 오독에 합의하는 클래스 (冊→申) 에 듣는 유일한 수단이에요.
review"normal" | "off"optional
"off" 로 하면 그 컬럼에 대해 엔진이 추정한 검토 사유 (text_mismatch 등) 를 내지 않아요. 선언한 규칙 (missing・pattern・min/max・enum・near 위반) 은 못 꺼요.
promptstringoptional
시트의 추출 지시 프롬프트예요 (선택).
mode"markdown" | "text"optional
문서 묶음의 변환 모드예요 (type=doc 용, 기본값 markdown). markdown 은 레이아웃 보존, text 는 원문 그대로예요.
요청
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 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": "청구서에서 금액과 날짜 추출"
  }'

응답 필드

pathstring
만들어진 아이템의 path 예요. sheet / doc / memo 는 uniqueKey 가 path 에 박혀서 돌아와요.
type"folder" | "sheet" | "doc" | "memo"
만들어진 종류예요.
uniqueKeystring
이름과 무관한 안정 키예요 (folder 제외). 이후 /view・/upload 의 path 세그먼트로 그대로 쓸 수 있어요.
응답
1
2
3
4
5
6
7
8
// 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" 으로 나타나요.
#
POST/uploadBearer₩100(부가세 포함) × N

이미지 업로드

시트 또는 문서 묶음에 이미지를 한 장 이상 올려요. multipart/form-data 예요. 기본은 비동기로, jobs 가 먼저 돌아오고 완료는 webhook 으로 알려드려요.

폼 필드 (multipart)

pathstringrequired
업로드할 시트 또는 문서 묶음의 path 예요. 묶음의 mode(markdown / text) 에 따라 변환 방식이 정해져요.
filesfile (repeatable)required
이미지 파일이에요. 여러 장 보낼 땐 files 를 반복해서 넣어주세요. 한 요청에 최대 20개, 파일당 최대 20MB, 요청 전체로는 28MB 까지예요 (넘으면 413).
waitbooleanoptional
true 로 주면 동기로 실행해요 (장당 최대 30s 까지 기다리고, 넘으면 status:"pending" 으로 돌려드려요). 한 장 업로드할 때 쓰기 좋아요.
요청
1
2
3
4
5
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"

응답 필드

pathstring
업로드한 대상의 path 예요.
jobsarray<Job>
비동기(기본)일 때 와요. 파일 하나가 잡 하나고, 완료는 ocr.completed 웹훅이나 GET /jobs/{jobId} 폴링으로 받아요.
uniqueKeystring
만들어진 행/페이지의 안정 키예요.
originalNamestring
원본 파일명이에요.
jobIdstring
GET /jobs/{jobId} 에 넘길 잡 ID 예요.
status"pending"
접수 시점엔 항상 pending 이에요.
resultsarray<Result>
wait=true 면 jobs 대신 와요. Job 의 속성에 더해, 끝난 건 mode 와 result 를 가져요 — result 는 GET /jobs 와 같은 v2 구조({ values, cells, review, image })예요. 30s 안에 못 끝난 건 status: "pending" 으로 남으니 /jobs 로 폴링해주세요.
응답
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
// 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"
    }
  }
}
#
POST/editBearer

시트 행/메모 편집

시트 셀 값이나 메모 본문을 덮어써요. anyOf 라서 (path, row, column, value) 둘 중 하나, 아니면 (path, text) 로 보내요. 편집되는 건 시트랑 메모뿐이에요 — 문서 묶음(.md / .txt)은 판독 결과 자체라 400 으로 거절돼요.

바디 파라미터

pathstringrequired
대상 시트/메모의 path 예요.
rowinteger | stringoptional
정수 인덱스 (음수는 끝에서부터) 나 rowKey 문자열로 줘요. 시트 편집할 땐 꼭 필요해요.
columninteger | stringoptional
정수 컬럼 인덱스나 컬럼명/id 로 줘요. 시트 편집할 땐 꼭 필요해요.
valueanyoptional
새로 덮어쓸 값이에요. 시트 편집할 땐 꼭 필요해요.
textstringoptional
새 메모 본문이에요. 메모 편집할 땐 꼭 필요해요.
요청
1
2
3
4
5
6
7
8
9
10
11
# 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":"새 본문"}'

응답 필드

okboolean
true 면 반영이 끝난 거예요.
patchedobject
실제로 적용된 변경 내용이에요. 시트 편집이면 { row, column, value }.
응답
1
{ "ok": true, "patched": { "row": "img_abc", "column": "amount", "value": "12000" } }
#
POST/removeBearer

삭제 (cascade)

폴더/시트/메모/이미지를 지워요. 폴더를 지우면 하위 메타데이터・flat 항목・Storage 까지 줄줄이 같이 삭제돼요.

바디 파라미터

pathstringrequired
지울 대상의 path 예요.
요청
1
2
3
4
curl -X POST https://api.space-ocr.com/remove \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"path":"/invoices/2024"}'

응답 필드

okboolean
true 면 삭제가 끝난 거예요 (폴더는 하위까지 cascade 완료).
응답
1
{ "ok": true }
#
GET/jobs/{jobId}Bearer

OCR 잡 폴링

POST /upload (비동기) 가 알려준 jobId 의 상태를 확인해요. webhook 을 안 쓸 때 유용해요.

패스 파라미터

jobIdstringrequired
/upload 응답의 jobs[].jobId 예요.
요청
1
2
curl https://api.space-ocr.com/jobs/job_xxx \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

jobIdstring
잡 ID 예요.
status"pending" | "done" | "failed"
처리 상태예요. failed 는 자동 환불돼 있어요.
uniqueKeystring
만들어진 행/페이지의 안정 키예요.
pathstring
아이템의 path 예요.
sheetRef / docRefstring | null
업로드 대상이 시트면 sheetRef, 문서 묶음이면 docRef 에 uniqueKey 가 들어와요 (다른 쪽은 null).
mode"sheet" | "markdown" | "text"
업로드 대상이 정하는 출력 형식이에요.
resultobject
status 가 done 일 때만 와요. { values, cells, review, image } — mode 와 무관하게 OCR 엔드포인트(/ocr/fields・/ocr/markdown・/ocr/text)와 같은 v2 구조고, values 안쪽만 mode 를 따라요. ocr.completed 웹훅의 data.result 도 같은 모양이에요.
응답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "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 }
  }
}
#
GET/amountBearer

잔액 및 무료 할당

지금 잔액과 남은 무료 할당을 알려드려요.
요청
1
2
curl https://api.space-ocr.com/amount \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

freeobject
매월 무료 할당이에요.
used / limit / remaininginteger
이번 사이클의 사용량·상한·남은 수예요.
cycleStartinteger (epoch ms)
사이클 시작 시각이에요.
cycleEndinteger (epoch ms)
사이클 종료 시각이에요 (여기서 리셋).
flatfeeobject
정액 플랜이에요. 미가입이면 enabled: false 고 다른 속성은 안 붙어요.
enabledboolean
가입 중인지예요.
used / limit / remaininginteger
이번 사이클의 사용량·상한·남은 수예요.
cycleStart / cycleEndinteger (epoch ms)
사이클 기간이에요.
nextBillingAtinteger (epoch ms)
다음 결제 시각이에요.
interval"monthly"
결제 주기예요.
renewalboolean
자동 갱신 여부예요.
planstring
플랜 이름이에요 (예: "pro").
balanceinteger

충전 잔액이에요. 단위는 통화가 아니라 스캔 수예요.

차감은 무료 할당 → 정액 플랜 → 잔액 순이라, 무료 할당이 남아 있는 동안 이 값은 줄지 않아요. 잔량 표시를 만드신다면 free.remaining 과 balance 를 둘 다 보여주세요 — balance 만 보여주면 무료 할당을 쓰는 동안 내내 안 줄어드는 숫자가 돼요.

currency"scans"
잔액의 단위예요.
perCallCostinteger
한 번 호출에 차감되는 스캔 수예요 (1).
응답
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
{
  "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.
// 소진 순서도 이 순서예요 (무료 → 정액 → 잔액).
#
GET/health

헬스 체크

인증 없이 부르는 헬스 체크예요. 아울러 변경 이력 두 벌을 돌려줘요 — 이 API 자체의 것(최상위 version・changelog)과, 지금 돌고 있는 엔진의 것(engine 아래의 버전・배포 시각・changelog)이에요. 오류율이나 검토 플래그 건수, 재실행 안정성을 측정하고 계시다면 측정값 옆에 두 버전을 같이 기록해 두세요. 나중에 수치가 움직였을 때 내 변경 탓인지 저희 갱신 탓인지 갈라낼 수 있어요. 요청/응답 형식(구조)은 안정된 계약이고, changelog 에 실리는 건 추가 키와 내용 수준의 변화뿐이에요.
요청
1
curl https://api.space-ocr.com/health

응답 필드

status"ok"
서비스가 응답할 수 있으면 ok 예요.
versionstring
공개 API 의 계약 버전이에요 (현재 v2.8). 엔진 버전은 engine.version 쪽이에요.
timeinteger (epoch ms)
서버 시각이에요.
changelogarray

이 API 자체의 변경 이력이에요. 최신 우선이고 엔트리마다 version・date・changes[] 가 있어요.

engine.changelog 와 헷갈리기 쉬우니 한 번만 정리할게요. 최상위 changelog 는 version(v2.x — 엔드포인트・파라미터・응답 키의 계약)과 짝이고, engine.changelog 는 engine.version(vNN — 읽기의 내용)과 짝이에요. 축이 달라서 한쪽만 움직이는 일이 있어요.

engineobject | null
지금 가동 중인 OCR 엔진의 정체예요. version (엔진 버전, 예: v81) / build.sha・build.deployed_at (배포된 커밋과 시각) / changelog (호출자가 관측할 수 있는 변경 이력, 최신 우선 — 엔트리마다 version・date・changes[]). 5분 캐시로 돌려주기 때문에 배포 직후엔 최대 5분 이전 값일 수 있어요. 엔진에 일시적으로 닿지 않으면 null 이 되는데, API 자체의 가동과는 별개 문제라 status 는 ok 그대로예요.
응답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "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` …", "…"] }
    ]
  }
}
#

개요

스페이스 전체에 Webhook URL 하나만 등록해두면, 모든 이벤트가 HMAC 서명이랑 같이 그쪽으로 가요. 설정은 Developer → Webhooks 에서 하거나, 아래의 Webhook 관리 엔드포인트로 해도 돼요.
#

이벤트

모든 이벤트는 동일한 envelope (event / deliveryId / occurredAt / apiVersion / data) 으로 와요.
item.createdeventoptional
/create 로 폴더/시트/메모가 만들어졌어요
upload.receivedeventoptional
/upload 로 이미지가 들어왔어요
ocr.completedeventoptional
OCR 끝났어요. data.mode 가 출력 형식(sheet / markdown / text), data.result 에 결과가 있어요
ocr.failedeventoptional
OCR 실패 (자동 환불해드렸어요)
webhook.testeventoptional
/webhook/test 로 직접 쏘는 테스트예요

페이로드 예시 — ocr.completed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "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 }
    }
  }
}
mode 는 업로드 대상이 정합니다 — 시트면 "sheet", 문서 묶음이면 "markdown" / "text" 예요. result 는 GET /jobs 와 같은 { values, cells, review, image }(v2) 이고, values 안쪽만 mode 를 따릅니다 (sheet: 필드 값 / markdown: { markdown, elements } / text: { text, blocks }). 문서 묶음이면 sheetRef 가 null 이고 docRef 에 묶음의 uniqueKey 가 들어옵니다.
#

전달 헤더

받는 쪽 엔드포인트에 아래 헤더가 같이 붙어요. 서명 검증에 필요한 건 Signature / Timestamp 두 개예요.
1
2
3
4
5
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
#

서명 검증

X-Spaceocr-Signature 헤더는 t=<unix_ms>,v1=<hex> 형식이에요. canonical 문자열은 `${t}.${rawBody}`, 알고리즘은 HMAC-SHA256 이에요. Replay 공격 막으려면 timestamp 가 5분 넘게 차이나는 건 거절해주세요.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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"),
  );
}
#

재시도 정책

2xx 가 아니면 exponential backoff (1m → 5m → 30m → 2h) 로 최대 5 번까지 시도해요. 5xx / 408 / 429 / timeout 만 재시도 대상이고, 다른 4xx 는 바로 dead 처리해요. 배달 로그는 30일 동안 보관해드려요.
↻수동 재발송은 POST /webhooks/deliveries/{deliveryId}/redeliver 로 해요. 자세한 건 배달 이력 섹션을 봐주세요.
#
GET/webhookBearer

현재 webhook 설정

지금 스페이스에 등록돼 있는 webhook URL 과 상태를 알려드려요.
요청
1
2
curl https://api.space-ocr.com/webhook \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

configuredboolean
등록돼 있는지예요. false 면 다른 필드는 안 붙어요.
urlstring
이벤트를 받는 URL 이에요.
activeboolean
전송이 켜져 있는지예요.
secretMaskedstring
서명 키의 끝 4자만 남긴 마스킹이에요. 평문은 등록·회전 그 한 번만 와요.
createdAt / updatedAtinteger (epoch ms)
등록·갱신 시각이에요.
응답
1
2
3
4
5
6
7
8
9
10
11
{
  "configured": true,
  "url": "https://example.com/hooks/space-ocr",
  "active": true,
  "secretMasked": "••••a1b2",
  "createdAt": 1716700000000,
  "updatedAt": 1716700000000
}

// 설정 안 돼 있을 때
{ "configured": false }
#
PUT/webhookBearer

webhook 설정 생성·갱신

스페이스 전체 webhook URL 을 등록하거나 바꿔요. rotateSecret 으로 서명 키도 새로 발급받을 수 있어요.

바디 파라미터

urlstring (uri)required
이벤트를 받을 URL 이에요.
activebooleanoptional
전송을 켤지 정해요 (기본은 true).
rotateSecretbooleanoptional
true 면 서명 키를 새로 발급하고, 새 secret 을 한 번만 돌려드려요. 처음 등록할 땐 이 값을 안 줘도 secret 이 발급되고, 그때도 평문으로 한 번 나와요.
요청
1
2
3
4
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 / url / active / secretMasked / createdAt / updatedAt—
GET /webhook 과 같은 필드예요.
secretstring
새로 발급되거나 rotateSecret 일 때만, 이 한 번만 평문으로 와요. 나중에 다시 꺼낼 수 없으니 바로 보관해주세요.
응답
1
2
3
4
5
6
7
8
9
{
  "configured": true,
  "url": "https://example.com/hooks/space-ocr",
  "active": true,
  "secretMasked": "••••a1b2",
  "secret": "kJ8s…",   // 새로 발급/회전할 때만, 이 한 번만 평문
  "createdAt": 1716700000000,
  "updatedAt": 1716700000000
}
#
DELETE/webhookBearer

webhook 설정 삭제

등록한 webhook 을 지워요. 그 뒤로는 이벤트가 안 가요.
요청
1
2
curl -X DELETE https://api.space-ocr.com/webhook \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

okboolean
true 면 삭제된 거예요. 그 뒤로는 이벤트가 안 가요.
응답
1
{ "ok": true }
#
POST/webhook/testBearer

테스트 이벤트 발사

설정해둔 URL 로 webhook.test 이벤트를 바로 쏴드려요. 받는 쪽 구현이 잘 됐는지 확인할 때 좋아요.
요청
1
2
curl -X POST https://api.space-ocr.com/webhook/test \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

okboolean
true 면 전송 큐에 실렸어요.
deliveryIdstring
발행된 배달 ID 예요. /webhooks/deliveries/{deliveryId} 로 결과를 추적할 수 있어요.
응답
1
{ "ok": true, "deliveryId": "dlv_xxx" }
#
GET/webhooks/deliveriesBearer

최근 전달 이력

최근 webhook 배달 로그를 보여드려요. 디버깅할 때 써요.

쿼리 파라미터

status"pending" | "success" | "dead"optional
배달 상태로 필터해요.
limitinteger 1..200optional
반환 건수 상한이에요. 기본 50.
요청
1
2
curl https://api.space-ocr.com/webhooks/deliveries \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

itemsarray<Delivery>
최신순 배달 로그예요. 로그는 30일 보관돼요.
deliveryIdstring
배달 ID 예요.
eventstring
이벤트 이름이에요 (ocr.completed 등).
urlstring
보낸 URL 이에요.
path / uniqueKeystring
대상 아이템의 path 와 키예요 (해당 이벤트만).
status"pending" | "success" | "dead"
pending 은 재시도 대기, dead 는 모든 시도가 끝난 상태예요.
attemptsinteger
시도 횟수예요.
lastAttemptobject
마지막 시도의 상세예요 — { at, attemptIndex, responseStatus, error, durationMs, responsePreview }.
occurredAtinteger (epoch ms)
이벤트 발생 시각이에요.
nextAttemptAtinteger | null
다음 재시도 예정이에요. 없으면 null.
completedAtinteger | null
성공 시각이에요.
응답
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "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
    }
  ]
}
#
GET/webhooks/deliveries/{deliveryId}Bearer

배달 상세

고른 배달의 전체 페이로드랑 시도 이력을 다 보여드려요.

패스 파라미터

deliveryIdstringrequired
/webhooks/deliveries 의 deliveryId 예요.
요청
1
2
curl https://api.space-ocr.com/webhooks/deliveries/dlv_xxx \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

deliveryIdstring
배달 ID 예요.
eventstring
이벤트 이름이에요.
occurredAtinteger (epoch ms)
이벤트 발생 시각이에요.
payloadobject
받는 쪽에 보낸 이벤트 본문 전체예요 (envelope 의 data 까지).
attempts[{ at, responseStatus, ok }]
시도 이력이에요.
응답
1
2
3
4
5
6
7
8
9
{
  "deliveryId": "dlv_xxx",
  "event": "ocr.completed",
  "occurredAt": 1716700000000,
  "payload": { /* full event body */ },
  "attempts": [
    { "at": 1716700000000, "responseStatus": 200, "ok": true }
  ]
}
#
POST/webhooks/deliveries/{deliveryId}/redeliverBearer

수동 재발송

실패한 배달을 수동으로 다시 쏴드려요.

패스 파라미터

deliveryIdstringrequired
다시 보낼 deliveryId 예요.
요청
1
2
curl -X POST https://api.space-ocr.com/webhooks/deliveries/dlv_xxx/redeliver \
  -H "Authorization: Bearer YOUR_API_KEY"

응답 필드

okboolean
true 면 재발송 큐에 실렸어요.
deliveryIdstring
같은 ID 를 그대로 다시 써요 (새 ID 를 만들지 않아요). 배달 로그의 status 가 pending 으로 돌아가고 attempts 에 시도가 덧붙어요.
응답
1
2
3
4
{ "ok": true, "deliveryId": "dlv_xxx" }

// 같은 deliveryId 를 그대로 다시 씁니다 (새 ID 를 만들지 않아요). 배달 로그의
// status 가 pending 으로 돌아가고 attempts 에 시도가 덧붙습니다.
#

개요

MCP 서버는 이 API 를 AI 에이전트의 툴로 그대로 열어 줍니다. 읽기 3종에 더해 폴더와 시트를 만들고, 사진을 올리고, 쌓인 행을 조건 걸어 꺼내는 것까지 에이전트가 합니다. 안에서 부르는 건 같은 REST 라우트라 과금도 같습니다.
#

연결

설치할 건 없습니다. 헤더를 설정할 수 있는 클라이언트는 API 키를 베어러 토큰으로 그대로 보내면 됩니다. 헤더를 못 넣는 Claude 데스크톱/모바일/claude.ai 에서는 이 URL 을 커스텀 커넥터로 추가하면 OAuth 동의 화면이 열려서, 어떤 API 키로 동작할지 고르면 됩니다. 어느 쪽이든 키는 그 요청에만 쓰이고 서버에 저장되지 않습니다.
1
2
3
# 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)

1
2
3
4
5
6
7
8
{
  "mcpServers": {
    "space-ocr": {
      "url": "https://mcp.space-ocr.com/mcp",
      "headers": { "Authorization": "Bearer YOUR_API_KEY" }
    }
  }
}
그 밖의 클라이언트에서도 표준 Streamable HTTP 엔드포인트라 같은 URL 과 헤더로 붙습니다. 이미지는 서버에서 받을 수 있는 https:// URL 이나 base64 / data URI 로 보내주세요.
#

툴 목록

읽기 3개와 작업공간 10개예요. 과금은 대응하는 REST 라우트와 같고, 읽기와 이미지 업로드만 크레딧을 씁니다. 공개 URL 이 아닌 이미지 — 로컬 파일, 대화에 첨부된 사진 — 는 space_inbox 로 넣습니다.
ocr_extract사진 한 장에서 이름 붙인 필드를 뽑아요. fields 스키마를 직접 정하거나 autoFields 로 제안받아요.1 크레딧
ocr_markdown레이아웃을 살린 마크다운이에요. 요소마다 좌표가 붙어요.1 크레딧
ocr_text읽기 순서를 되살린 원문 텍스트예요. 블록마다 좌표가 붙어요.1 크레딧
space_guide이 서버 사용법 가이드를 읽어요 (start / upload / schemas / verification / queries / workflows). 네트워크도 크레딧도 쓰지 않아요.무료
space_list트리를 훑어봐요 (GET /space).무료
space_view아이템 내용을 읽어요. 시트는 where / sort / select / limit 으로 조회해요 (GET /view). 좌표는 boxes: true 일 때만 옵니다.무료
space_create폴더 / 시트 / 문서 묶음 / 메모를 만들어요 (POST /create).무료
space_inbox시트나 묶음으로 보낼 업로드 링크를 발급해요. 공개 URL 이 아닌 이미지 — 로컬 파일, 대화에 첨부된 사진 — 를 넣는 경로가 이겁니다. 링크에는 만료 시각이 있어요.장당 1 크레딧
space_upload이미 공개 https:// URL 인 이미지를 시트나 묶음에 최대 20장 올려요 (POST /upload).장당 1 크레딧
space_job비동기 업로드 잡 상태를 확인해요 (GET /jobs).무료
space_edit시트 셀 값이나 메모 본문을 고쳐요 (POST /edit).무료
space_balance남은 무료 사용량과 플랜 한도, 잔액을 봐요 (GET /amount).무료
space_delete아이템을 삭제해요 (POST /remove, 폴더는 안의 것까지). 2단계로 동작해요: confirm 없이 부르면 아무것도 안 지우고 사라질 항목 수와 서명된 토큰만 돌려줘요. 사용자에게 보여 주고 동의를 받은 뒤 그 토큰을 붙여 다시 부르면 삭제돼요. 루트는 거부해요.무료

삭제는 2단계

space_delete 를 `confirm` 없이 부르면 아무것도 지우지 않고 «사라질 것» 만 돌아옵니다: 대상, 그 아래의 폴더/시트/문서 묶음/메모/이미지 개수, 샘플, 그리고 `confirm` 토큰이에요. 이 토큰은 호출한 API 키와 대상 경로에 묶인 서명이라 모델이 지어낼 수 없고, 그래서 사용자에게 보여 주는 단계를 건너뛸 수 없습니다. 동의를 받으면 그 토큰을 붙여 다시 부르면 돼요. 토큰은 10~20분 동안 유효하고, 루트 경로는 언제나 거부됩니다.
◆삭제는 되돌릴 수 없습니다. 폴더를 지우면 안에 있던 이미지까지 함께 사라집니다.
툴 말고 MCP 리소스와 프롬프트도 함께 제공합니다. 리소스(space-ocr://guide/schemas · /verification · /queries · /workflows)에는 스키마 짜는 법, 검증 플래그 읽는 법, 필터 쓰는 법, 일괄 처리 순서가 담겨 있고 필요할 때만 읽힙니다. 프롬프트(file_documents · review_flagged · ask_documents)는 정형 작업 절차예요. 둘 다 MCP 선택 기능이라 클라이언트에 따라 지원 여부가 갈립니다.