# 웹훅 가이드 — 배치 완료 콜백

> 폴링 대신 배치 완료 시 알림을 받을 콜백 URL을 등록하세요. Standard Webhooks 서명 검증, 이벤트 페이로드 구조, 재시도 동작, bbox 완료 이벤트, 그리고 배치 재처리 시 자동으로 다시 알림을 받는 방법을 다룹니다.

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

## 웹훅 등록하기

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

```bash
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`이 포함됩니다. 계정 전체에 공유되는 키가 아니라 등록마다 하나의 비밀키이므로, 유출되어도 해당 콜백에만 영향을 줍니다:

```json
{
  "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은 아래 두 종류의 이벤트를 모두 수신할 수 있습니다.

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

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

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

```json
{
  "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입니다:

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

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

**cURL**

```bash
# 서명 검증은 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
```

**Python**

```python
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())
```

**Javascript**

```javascript
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`에 대해 의도적으로 관여하지 않았고 여전히 그렇습니다([웹훅 참조](/developers/reference/webhooks) 참조). 이제는 `process`가 재설정을 자동으로 처리합니다. 이전 조언에 따라 통합을 작성한 경우 각 웨이브 전에 재등록을 안전하게 중단할 수 있습니다. 이전에는 아무 효과가 없었습니다.

---

Source: https://imagetotable.ai/ko/developers/guides/webhooks
