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

OCR API와 웹훅으로 AP 자동화 파이프라인 구축하기

안정적인 미지급금 자동화 파이프라인 구축 방법을 알아보세요. 비동기 OCR API로 인보이스를 업로드하고, 보안 웹훅을 통해 구조화된 데이터를 받아보세요.

7 분 분량· 2026-08-31

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

AP 자동화를 통해 처리되는 인보이스
인보이스 입력, 구조화된 데이터와 웹훅 출력.

이것이 바로 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번이 대표 사유입니다.

webhook-receiver.js
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
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'));
✓ Verified

space-ocr는 어떻게 페이지에서 데이터 위치를 찾아낼까요? 기반 언어 모델이 추출된 텍스트와 함께 예상 위치를 가리키는 토큰 힌트를 반환합니다. 그러면 저희 엔진은 핵심적인 검증 단계를 수행합니다. 바로 추출된 값을 페이지에서 실제로 감지된 OCR 기호들과 글자 단위로 하나씩 대조하는 것입니다. 이 과정을 통해 0.0에서 1.0 사이의 match_ratio 점수가 생성됩니다. 0.85 이상의 점수는 신뢰도 높은 일치를 의미합니다. 최종 좌표는 원본 이미지의 픽셀 크기와 무관하게 0-1000으로 정규화된 경계 상자(bounding box)로 반환됩니다.

이러한 자동화 시스템은 누구나 쉽게 이용할 수 있어야 합니다. API를 통해 처리되는 이미지는 한 장당 ₩100(부가세 포함)입니다. 모든 계정에는 매월 100건의 무료 스캔이 제공됩니다. 중요한 점은, 이미지를 읽을 수 없어 OCR 작업이 실패하는 경우에는 비용이 청구되지 않는다는 것입니다. 성공적으로 데이터를 추출한 경우에만 비용을 지불하므로, 부담 없이 미지급금 처리 자동화를 시작할 수 있습니다.

  1. 공개 엔드포인트 노출하기
    서버는 공개 URL이 필요합니다. 로컬 개발 환경에서는 ngrok과 같은 서비스를 사용하여 로컬 서버를 인터넷에 노출할 수 있습니다.
  2. 웹훅 URL 등록하기
    space-ocr 대시보드의 웹훅 설정에서 공개 엔드포인트 URL 을 추가하거나, `PUT /webhook` 에 `url` 과 `active: true` 를 보내 등록합니다. 서명용 시크릿은 처음 발급될 때와 `rotateSecret` 으로 재발급할 때 딱 한 번만 평문으로 내려오니 그 자리에서 안전하게 보관하세요.
  3. 시그니처 검증 구현하기
    `X-Spaceocr-Signature` 를 `t=<unix_ms>,v1=<hex>` 로 파싱하고, `${t}.${rawBody}` 에 시크릿으로 HMAC-SHA256 을 계산해 `v1` 과 timing-safe 비교한 뒤, timestamp 가 5분 넘게 어긋나면 거절합니다. 구현이 끝나면 `POST /webhook/test` 로 테스트 전송을 보내 경로 전체를 확인하세요.
  4. 'ocr.completed' 이벤트 처리하기
    유효한 이벤트가 도착하면 `data.result`(`{ values, cells, review, image }`)를 읽습니다. `values` 는 회계 시스템에 기록하고, `review.flagged` 의 각 항목(`path` 와 순위가 매겨진 `reasons`)은 담당자 확인으로 넘기세요. `cells[path]` 를 쓰면 그 값이 지면 어디에 있는지도 함께 보여 줄 수 있습니다.
  5. 비동기식으로 인보이스 업로드하기
    파일과 함께 `POST /upload` 요청을 보내세요(한 요청에 최대 20개). API 는 OCR 작업이 끝날 때까지 기다리지 않고, 파일마다 `jobId` 가 담긴 `jobs[]` 를 즉시 반환합니다.
  6. 이벤트 수신 확인 및 모니터링
    엔드포인트는 수신 확인을 위해 신속하게 2xx 상태 코드를 반환해야 합니다. 2xx 가 아니면 1분 → 5분 → 30분 → 2시간 일정으로 재시도됩니다. 전송 상태는 `GET /webhooks/deliveries`(status 필터, `attempts` 와 `responsePreview` 확인)나 대시보드에서 확인할 수 있고, 실패한 건은 `POST /webhooks/deliveries/{deliveryId}/redeliver` 로 다시 보낼 수 있습니다.
웹훅 엔드포인트가 다운되면 어떻게 되나요?
전송은 지수 백오프(1분 → 5분 → 30분 → 2시간)로 총 5회까지 재시도합니다. 재시도 대상은 5xx 서버 오류, 408, 429, 타임아웃이고 그 밖의 4xx 는 즉시 dead 로 처리됩니다. 전송 로그는 30일간 보관하며, `POST /webhooks/deliveries/{deliveryId}/redeliver` 로 직접 재전송할 수도 있습니다. 이때 deliveryId 는 같은 값을 다시 쓰고 상태는 pending 으로 돌아갑니다.
웹훅 요청이 실제로 space-ocr에서 온 것인지 어떻게 확인하나요?
모든 전송에는 `X-Spaceocr-Signature` 헤더가 `t=<unix_ms>,v1=<hex>` 형식으로 붙고, `X-Spaceocr-Timestamp` · `X-Spaceocr-Event` · `X-Spaceocr-Delivery` 도 함께 옵니다. 검증은 canonical 문자열 `${t}.${rawBody}` 에 웹훅 시크릿으로 HMAC-SHA256 을 계산해 그 hex 를 `v1` 과 timing-safe 방식으로 비교하고, timestamp 가 5분 넘게 차이 나면 거절하는 순서입니다. 서명 대상은 원본 바디이므로 JSON 파싱으로 형태가 바뀌기 전에 검증해야 합니다.
한 번의 API 호출로 여러 인보이스를 업로드할 수 있나요?
네. `POST /upload` 는 multipart/form-data 를 받고 한 요청에 `files` 를 최대 20개까지 담을 수 있습니다. 한도는 파일당 20MB, 요청 전체 28MB 이며 넘으면 413 입니다. 응답은 파일마다 한 항목인 `jobs[]` 배열이고, `jobId` 는 `GET /jobs/{jobId}` 로 바로 조회할 수 있습니다. `wait=true` 를 붙이면 동기 호출이 되어 이미지당 최대 30초까지 기다린 뒤 `results[]` 를 돌려주며, 그 안에 못 끝난 항목은 `pending` 상태로 옵니다.
OCR 작업이 실패해도 비용이 청구되나요?
아니요. OCR 작업이 실패하면 `ocr.failed` 이벤트가 발생하며 스캔 비용은 자동으로 환불됩니다. 성공적으로 추출된 건에 대해서만 비용을 지불하시면 됩니다.
폴링 방식과 웹훅 사용의 차이점은 무엇인가요?
폴링은 작업 완료 여부를 확인하기 위해 `GET /jobs/{jobId}`를 반복적으로 호출해야 합니다. 반면 웹훅은 이벤트 기반 방식으로, 저희 서버가 결과가 준비되는 즉시 사용자의 엔드포인트로 결과를 전송(push)해 줍니다. 따라서 훨씬 효율적이며 실시간 업데이트가 가능합니다.
애플리케이션에서 업로드를 안전하게 재시도하려면 어떻게 해야 하나요?
`/upload` 엔드포인트는 `Idempotency-Key` 헤더를 지원합니다. 24시간 이내에 동일한 키를 보내면 중복된 작업이 생성되는 대신 기존의 캐시된 응답을 받게 되어 이중 처리를 방지할 수 있습니다.

지금 바로 AP 워크플로우 자동화를 시작하세요

API 키를 발급받고 매월 제공되는 100건의 무료 스캔으로 개발을 시작해 보세요.

관련 글