가이드

웹훅

GET /batches/{batch_name}을 계속 폴링하며 완료를 기다리는 대신, 콜백 URL을 한 번 등록하면 배치가 완료되는 즉시 HTTP POST를 받을 수 있습니다. 세 가지 서명 헤더와 id.timestamp.body 서명 콘텐츠 구성 방식은 Standard Webhooks 오픈 사양을 따릅니다. 이는 OpenAI의 웹훅에서도 사용하는 방식입니다. 사양 자체의 규칙과 한 가지 차이점: 여기서 webhook_secret은 일반 16진수 문자열이며, whsec_ 접두사가 붙은 base64 값이 아닙니다. 따라서 기성 Standard Webhooks/Svix 검증 라이브러리는 이를 base64로 디코딩하려다 실패합니다. 반환된 그대로의 비밀 키를 원시 HMAC 키 바이트로 사용하고, 접두사를 인식하는 라이브러리 대신 아래의 검증 코드를 사용하세요.

웹훅 등록하기

PUT /api/v1/batches/{batch_name}/webhook으로 배치의 콜백을 등록합니다:

curl -X PUT https://imagetotable.ai/api/v1/batches/260716-4K9P/webhook \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"callback_url": "https://example.com/hooks/imagetotable"}'

응답에는 등록 시 생성된 웹훅별 webhook_secret이 포함됩니다. 계정 전체에 공유되는 키가 아니라 등록마다 하나의 비밀키이므로, 유출되어도 해당 콜백에만 영향을 줍니다:

{
  "batch_name": "260716-4K9P",
  "callback_url": "https://example.com/hooks/imagetotable",
  "webhook_secret": "9f2a3b7c1d8e4f5061728394a5b6c7d81a2b3c4d5e6f7089",
  "created_at": "2026-07-16T09:12:03Z",
  "fired_at": null
}

webhook_secret을 저장해두세요. 수신된 전달을 검증할 때 필요합니다. 별도의 "비밀키 확인" 엔드포인트는 없지만, 같은 callback_url로 다시 PUT하면 안전하게 다시 가져올 수 있습니다.

이벤트 봉투

모든 전달은 봉투에 담긴 JSON 객체입니다. 원시 배치 데이터가 직접 전달되지 않으므로, 향후 이벤트 유형이 추가되어도 기존 통합을 깨뜨리지 않고 구조를 확장할 수 있습니다. type을 확인하여 이벤트를 구분하세요. 배치에 등록된 콜백 URL은 아래 두 종류의 이벤트를 모두 수신할 수 있습니다.

{
  "type": "batch.completed",
  "created_at": "2026-07-16T09:14:31Z",
  "data": {
    "batch_name": "260716-4K9P",
    "status": "succeeded",
    "document_count": 3
  }
}

data.status는 배치의 최종 결과를 나타냅니다(succeeded 또는 failed — 전체 상태 용어는 비동기 작업 모델 참조). 페이로드는 완료 알림일 뿐, 결과 자체는 아닙니다. 수신 후 GET /batches/{batch_name}/results를 호출하여 실제 추출된 데이터를 가져오세요.

문서에서 bbox 주석 작업을 트리거하면 해당 문서의 배치에 이미 등록된 웹훅이 재사용되며, batch.completed 대신 별도의 이벤트가 전달됩니다:

{
  "type": "document.bbox_completed",
  "created_at": "2026-07-16T09:20:07Z",
  "data": {
    "document_id": "3f9c9e2a-1b7d-4e2b-9a3e-2f5b6c7d8e9f",
    "group_batch_id": "bx_8a3c2e1f",
    "status": "succeeded"
  }
}

batch.completed와 마찬가지로, 이 이벤트의 전달은 원자적으로 처리됩니다. 따라서 여러 행 그룹이 서로 다른 스레드에서 거의 동시에 종료 상태에 도달하더라도 하나의 bbox 작업에 대해 두 개의 개별 document.bbox_completed 전달이 발생하지 않습니다. 전달 실패 시 재시도되는 일반적인 경우에는 webhook-id 기반 중복 제거가 추가로 적용됩니다.

서명 확인

Standard Webhooks 사양에 따라 모든 전송에는 세 개의 헤더가 포함됩니다:

헤더목적
webhook-id이 전송 시도에 대한 고유 ID입니다. 중복 제거에 사용하세요. 재시도된 전송은 동일한 ID를 재사용합니다.
webhook-timestamp전송이 이루어진 Unix 타임스탬프로, 재전송 공격을 방어합니다.
webhook-signature서명 자체로, v1,<base64 서명> 형식입니다.

서명은 webhook-id, webhook-timestamp, 원시 요청 본문을 순서대로 .로 연결한 문자열에 대해 웹훅의 webhook_secret을 키로 사용한 HMAC-SHA256입니다:

signed_content = "{webhook_id}.{webhook_timestamp}.{raw_request_body}"
signature      = base64(hmac_sha256(webhook_secret, signed_content))

이를 서버에서 다시 계산하여 webhook-signature 헤더의 v1, 뒤에 있는 값과 비교하세요. 항상 원시 요청 본문 바이트를 기준으로 검증해야 하며, 파싱된 JSON을 다시 직렬화한 버전이 아닙니다. 다시 직렬화하면 공백/키 순서가 변경되어 실제 전송임에도 서명 불일치가 발생할 수 있습니다.

# 서명 검증은 HTTP 호출이 아니므로, 이미 디스크에 저장된 전달 데이터(headers.txt / body.json)를
# openssl로 재현합니다. 실제 웹훅 핸들러에 검증 코드를 작성하기 전에 수동으로 스키마를 이해하는 데 유용합니다.
WEBHOOK_ID=$(grep -i '^webhook-id:' headers.txt | cut -d' ' -f2 | tr -d '\r')
WEBHOOK_TS=$(grep -i '^webhook-timestamp:' headers.txt | cut -d' ' -f2 | tr -d '\r')
SIGNED_CONTENT="${WEBHOOK_ID}.${WEBHOOK_TS}.$(cat body.json)"

echo -n "$SIGNED_CONTENT" \
  | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -binary \
  | base64
import base64
import hashlib
import hmac
import os

headers = dict(line.split(": ", 1) for line in open("headers.txt") if ": " in line)
webhook_id = headers["webhook-id"].strip()
webhook_timestamp = headers["webhook-timestamp"].strip()
body = open("body.json", "rb").read()

signed_content = f"{webhook_id}.{webhook_timestamp}.".encode() + body
signature = base64.b64encode(
    hmac.new(os.environ["WEBHOOK_SECRET"].encode(), signed_content, hashlib.sha256).digest()
)
print(signature.decode())
import { createHmac } from "node:crypto";
import { readFileSync } from "node:fs";

const headers = Object.fromEntries(
  readFileSync("headers.txt", "utf8")
    .split("\n")
    .filter((line) => line.includes(": "))
    .map((line) => line.split(": "))
);
const webhookId = headers["webhook-id"].trim();
const webhookTimestamp = headers["webhook-timestamp"].trim();
const body = readFileSync("body.json", "utf8");

const signedContent = `${webhookId}.${webhookTimestamp}.${body}`;
const signature = createHmac("sha256", process.env.WEBHOOK_SECRET)
  .update(signedContent)
  .digest("base64");
console.log(signature);

수동으로 테스트할 때 — 위 스니펫 중 하나를 실행하기 전에 전달된 본문을 직접 body.json에 저장하는 경우 — 편집기나 캡처 방식이 추가하는 후행 개행 문자를 주의하세요. 위의 curl/openssl 버전은 우연히 이 문제에 영향을 받지 않지만(bash의 $(cat ...)는 후행 개행을 제거함), Python과 JavaScript 버전은 파일의 정확한 바이트를 읽으므로 추가 \n이 들어가면 일치하지 않는 서명을 조용히 생성합니다. 전형적인 증상은 "curl에서는 유효하다고 나오는데 Python 검증기에서는 유효하지 않다고 나온다"는 것이며, 코드 자체에는 버그가 없기 때문에 더 혼란스럽습니다. 실제 웹훅 핸들러에서는 복사/붙여넣기나 수동 저장 대신 웹 프레임워크의 원시 요청 본문 접근자(Flask의 request.get_data(), Express의 raw-body 미들웨어)를 통해 본문을 캡처하세요. 그러면 프로덕션에서는 이 문제가 발생하지 않으며, 수동 테스트 시에만 주의할 함정입니다.

재시도

배치가 완료되면 즉시 한 번 전송이 시도됩니다. 해당 시도가 실패하면 지수 백오프 방식으로 재시도됩니다: 1분, 5분, 30분, 2시간, 6시간, 24시간 — 총 7회 시도까지 이루어집니다. 첫 번째 시도 후 24시간 이내에 성공하지 못하면 전송은 실패로 표시되고 더 이상 재시도되지 않습니다. 이벤트를 안전하게 기록하는 즉시 엔드포인트가 2xx 상태로 응답하도록 하세요. 느린 처리는 응답 후에 수행하고, 응답 전에 수행하지 마세요. 그래야 느린 다운스트림 단계가 자체적으로 재시도를 유발하지 않습니다.

배치 재처리: 웨이브당 하나의 알림

특정 batch.completed 알림은 배치 내 모든 문서가 최종 상태에 도달하는 순간 한 번만 전송됩니다. 이후 동일한 batch_name에 문서를 더 추가하고 process를 다시 호출하면 — "새로운 웨이브" — 해당 웨이브가 완료될 때도 자동으로 다시 알림을 받게 됩니다. process_batch는 이전 웨이브에서 이미 알림을 보낸 배치에 새로운 적격 문서가 있는 순간 웹훅을 자동으로 재설정합니다. 웨이브 사이에 웹훅을 다시 PUT하거나 다른 작업을 수행할 필요가 없습니다.

두 웨이브가 겹치는 경우 — 이전 웨이브가 완료된 후가 아니라 실행 중인 동안 문서를 추가하고 처리하면 — 두 웨이브의 모든 문서를 포함하는 하나의 알림을 받게 되며, 두 웨이브 중 마지막 문서가 완료되면 전송됩니다. 두 개의 개별 알림을 받는 경우는 배치가 실제로 유휴 상태가 되었을 때 두 번의 process 호출 사이에만 발생합니다.

이 문서의 이전 버전에서는 한 번만 전송되는 것을 영구적인 제한 사항으로 설명하며, 각 웨이브 전에 웹훅을 다시 PUT하여 해결해야 한다고 안내했습니다. 그 조언은 실제로 효과가 없었습니다. 등록 엔드포인트는 fired_at에 대해 의도적으로 관여하지 않았고 여전히 그렇습니다(웹훅 참조 참조). 이제는 process가 재설정을 자동으로 처리합니다. 이전 조언에 따라 통합을 작성한 경우 각 웨이브 전에 재등록을 안전하게 중단할 수 있습니다. 이전에는 아무 효과가 없었습니다.

📮 contact email: [email protected]