OCR API와 웹훅으로 AP 자동화 파이프라인 구축하기
안정적인 미지급금 자동화 파이프라인 구축 방법을 알아보세요. 비동기 OCR API로 인보이스를 업로드하고, 보안 웹훅을 통해 구조화된 데이터를 받아보세요.
인보이스 처리는 대표적인 병목 구간입니다. 수동 데이터 입력은 느리고 오류가 발생하기 쉽습니다. OCR API를 사용하더라도 작업 완료 여부를 계속 확인하는 폴링(polling) 방식은 비효율적입니다. 시스템 복잡도와 지연 시간을 높이고 불필요한 트래픽만 유발할 뿐입니다. 진정한 미지급금 자동화 워크플로우는 '아직 안 끝났나?'라고 계속 물어보며 기다리는 방식이어서는 안 됩니다. 데이터가 준비되는 즉시 반응하는 이벤트 기반(event-driven) 방식이어야 합니다.

이것이 바로 space-ocr 비동기 처리 방식의 핵심 원리입니다. 요청을 보내고 연결을 계속 유지하는 대신, 여러 인보이스를 한 번에 업로드하고 바로 다른 작업을 시작할 수 있습니다. API는 파일이 정상적으로 수신되었음을 확인하는 작업 식별자 목록을 즉시 반환합니다. 그 후 저희 엔진이 각 이미지를 처리합니다. 인보이스 인식이 성공적으로 완료되면, 애플리케이션의 엔드포인트로 알림이 직접 전송됩니다. 이 알림은 구조화된 데이터를 포함한 간단하고 신뢰성 높은 HTTP POST 요청, 즉 웹훅(webhook)을 통해 이루어집니다.
작업 흐름은 간단합니다. 인보이스 이미지를 첨부해 POST /upload를 한 번 호출하면 됩니다. 형식은 multipart/form-data 이고 한 요청에 files 는 최대 20개, 파일당 20MB, 요청 전체는 28MB 까지입니다. 초과하면 413 이 돌아옵니다. 기본 동작은 비동기라 응답은 파일 하나당 한 항목씩 담긴 jobs[] 배열이며, 각 항목에 jobId 와 status: "pending" 이 들어 있습니다. 잠시 후, 서버는 웹훅을 통해 ocr.completed 이벤트를 수신합니다. 모든 이벤트는 같은 envelope(event / deliveryId / occurredAt / apiVersion / data)를 쓰고, 이 이벤트의 data.result 에는 GET /jobs/{jobId} 와 동일한 v2 구조 { values, cells, review, image } 가 담깁니다. values 는 인보이스 데이터 자체로 "弥生サンブル" 같은 공급업체 이름부터 각 라인 아이템까지 들어 있고, cells[path] 에는 그 값이 지면에서 차지하는 box 와 quad 가, review.flagged 에는 사람이 확인해야 할 경로 목록이 들어 있습니다. 안정성을 높이기 위해 모든 업로드 요청에 Idempotency-Key 헤더를 포함할 수 있습니다. 네트워크 오류로 요청을 재시도할 때 동일한 키를 보내면 중복된 처리 작업이 생성되는 것을 방지할 수 있습니다.
필드 자체는 인보이스가 쌓이는 시트 쪽에서 한 번만 선언합니다(POST /create 의 columns). 선언은 모델에 전달되지 않으므로 추출 정확도가 올라가지는 않습니다. 대신 검토 신호와 결정론적 파싱 결과가 붙습니다.
invoice_no—required와pattern. 번호가 비어 있으면missing, 형식이 어긋나면pattern_mismatch가 섭니다.invoice_date—type: "date". 해석된 값이data.normalized에 담기고, 인쇄된 날짜를 날짜로 읽을 수 없으면type_mismatch가 됩니다.total—type: "number"에min: 0. 음수이거나 숫자로 읽히지 않는 금액은out_of_range또는type_mismatch로 드러납니다.supplier— 거래처가 고정 목록이면enum, 그렇지 않으면near/not_near로 이름 옆에 인쇄되어야 할(또는 인쇄되면 안 되는) 어휘를 지정해near_mismatch/near_conflict로 드러냅니다.
선언이 values 를 덮어쓰는 일은 없습니다. 해석된 값은 data.normalized 에 따로 담기고, 사유는 review.flagged[].reasons 배열로 옵니다. 순위대로 정렬된 배열이라 0번이 대표 사유입니다.
const express = require('express');
const crypto = require('crypto');
const app = express();
const webhookSecret = process.env.SPACE_OCR_WEBHOOK_SECRET;
// The signature covers the raw bytes, so keep a copy before the JSON parser runs.
// Raise the parser limit too: an ocr.completed payload carries a cell per field.
const rawBodySaver = (req, res, buf, encoding) => {
if (buf && buf.length) {
req.rawBody = buf.toString(encoding || 'utf8');
}
};
app.use(express.json({ verify: rawBodySaver, limit: '5mb' }));
// X-Spaceocr-Signature: t=<unix_ms>,v1=<hex>
// Canonical string: `${t}.${rawBody}` — HMAC-SHA256 with your webhook secret.
function verifySignature(secret, header, rawBody) {
const m = /^t=(\d+),v1=([a-f0-9]+)$/.exec(header || '');
if (!m) return false;
const [, t, v1] = m;
// Replay guard: reject a timestamp that drifts more than 5 minutes.
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'),
'hex',
);
const received = Buffer.from(v1, 'hex');
return (
expected.length === received.length &&
crypto.timingSafeEqual(expected, received)
);
}
app.post('/webhook', (req, res) => {
const signature = req.get('X-Spaceocr-Signature');
if (!verifySignature(webhookSecret, signature, req.rawBody)) {
return res.status(400).send('Invalid signature');
}
// A redelivery reuses the same id, so it doubles as the de-duplication key.
const deliveryId = req.get('X-Spaceocr-Delivery');
const event = req.body; // event / deliveryId / occurredAt / apiVersion / data
switch (event.event) {
case 'ocr.completed': {
// result = { values, cells, review, image } — the same v2 shape as GET /jobs/{jobId}.
const { path, mode, result } = event.data;
const flagged = result.review?.flagged || [];
console.log(`[${deliveryId}] ${path} (${mode})`, result.values);
if (flagged.length) {
// Each entry is { path, reasons }; cells[path] holds box / quad / evidence.
console.warn(
`${flagged.length} field(s) to review:`,
flagged.map((f) => `${f.path} -> ${f.reasons[0]}`),
);
}
// TODO: post result.values to your accounting system, and hold the
// flagged paths for a person before the invoice is approved.
break;
}
case 'ocr.failed':
// The scan is refunded automatically; keep the payload for the audit trail.
console.error(`[${deliveryId}] OCR failed:`, event.data);
break;
case 'webhook.test':
console.log(`[${deliveryId}] test delivery received`);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
// Anything other than 2xx is retried: 1m -> 5m -> 30m -> 2h, 5 attempts in total.
// Keep this fast — hand slow work to a queue instead of holding the response open.
res.status(200).send({ received: true });
});
app.listen(3000, () => console.log('Webhook receiver listening on port 3000'));space-ocr는 어떻게 페이지에서 데이터 위치를 찾아낼까요? 기반 언어 모델이 추출된 텍스트와 함께 예상 위치를 가리키는 토큰 힌트를 반환합니다. 그러면 저희 엔진은 핵심적인 검증 단계를 수행합니다. 바로 추출된 값을 페이지에서 실제로 감지된 OCR 기호들과 글자 단위로 하나씩 대조하는 것입니다. 이 과정을 통해 0.0에서 1.0 사이의 match_ratio 점수가 생성됩니다. 0.85 이상의 점수는 신뢰도 높은 일치를 의미합니다. 최종 좌표는 원본 이미지의 픽셀 크기와 무관하게 0-1000으로 정규화된 경계 상자(bounding box)로 반환됩니다.
이러한 자동화 시스템은 누구나 쉽게 이용할 수 있어야 합니다. API를 통해 처리되는 이미지는 한 장당 ₩100(부가세 포함)입니다. 모든 계정에는 매월 100건의 무료 스캔이 제공됩니다. 중요한 점은, 이미지를 읽을 수 없어 OCR 작업이 실패하는 경우에는 비용이 청구되지 않는다는 것입니다. 성공적으로 데이터를 추출한 경우에만 비용을 지불하므로, 부담 없이 미지급금 처리 자동화를 시작할 수 있습니다.
- 공개 엔드포인트 노출하기서버는 공개 URL이 필요합니다. 로컬 개발 환경에서는 ngrok과 같은 서비스를 사용하여 로컬 서버를 인터넷에 노출할 수 있습니다.
- 웹훅 URL 등록하기space-ocr 대시보드의 웹훅 설정에서 공개 엔드포인트 URL 을 추가하거나, `PUT /webhook` 에 `url` 과 `active: true` 를 보내 등록합니다. 서명용 시크릿은 처음 발급될 때와 `rotateSecret` 으로 재발급할 때 딱 한 번만 평문으로 내려오니 그 자리에서 안전하게 보관하세요.
- 시그니처 검증 구현하기`X-Spaceocr-Signature` 를 `t=<unix_ms>,v1=<hex>` 로 파싱하고, `${t}.${rawBody}` 에 시크릿으로 HMAC-SHA256 을 계산해 `v1` 과 timing-safe 비교한 뒤, timestamp 가 5분 넘게 어긋나면 거절합니다. 구현이 끝나면 `POST /webhook/test` 로 테스트 전송을 보내 경로 전체를 확인하세요.
- 'ocr.completed' 이벤트 처리하기유효한 이벤트가 도착하면 `data.result`(`{ values, cells, review, image }`)를 읽습니다. `values` 는 회계 시스템에 기록하고, `review.flagged` 의 각 항목(`path` 와 순위가 매겨진 `reasons`)은 담당자 확인으로 넘기세요. `cells[path]` 를 쓰면 그 값이 지면 어디에 있는지도 함께 보여 줄 수 있습니다.
- 비동기식으로 인보이스 업로드하기파일과 함께 `POST /upload` 요청을 보내세요(한 요청에 최대 20개). API 는 OCR 작업이 끝날 때까지 기다리지 않고, 파일마다 `jobId` 가 담긴 `jobs[]` 를 즉시 반환합니다.
- 이벤트 수신 확인 및 모니터링엔드포인트는 수신 확인을 위해 신속하게 2xx 상태 코드를 반환해야 합니다. 2xx 가 아니면 1분 → 5분 → 30분 → 2시간 일정으로 재시도됩니다. 전송 상태는 `GET /webhooks/deliveries`(status 필터, `attempts` 와 `responsePreview` 확인)나 대시보드에서 확인할 수 있고, 실패한 건은 `POST /webhooks/deliveries/{deliveryId}/redeliver` 로 다시 보낼 수 있습니다.