> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bizmori.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 웹훅

> 주문 완료 또는 실패 시 실시간 푸시 알림 받기

웹훅은 주문 처리가 완료되거나 실패할 때 서버에 HTTP `POST` 콜백을 전달합니다. [주문 조회](/ko/api-reference/orders/get-order) 엔드포인트를 반복 폴링하는 대신 웹훅을 사용하면 결과가 준비되는 즉시 알림을 받을 수 있습니다.

## 웹훅 설정

<Steps>
  <Step title="웹훅 엔드포인트 등록">
    API 또는 [BIZ MORI 대시보드](https://app.bizmori.com/webhooks)에서 웹훅 엔드포인트를 생성할 수 있습니다. API를 통해 생성하려면 [웹훅 생성](/ko/api-reference/webhooks/create-webhook) 엔드포인트를 사용합니다:

    ```bash theme={null}
    curl -X POST https://api.bizmori.com/api/v2/orders/webhooks \
      -H "Authorization: Bearer YOUR_API_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{
        "name": "내 웹훅",
        "url": "https://your-server.com/webhook"
      }'
    ```

    **응답:**

    ```json theme={null}
    {
      "data": {
        "id": 1,
        "name": "내 웹훅",
        "secret": "whsec_xxxxxxxxxxxxxxxxxx"
      }
    }
    ```

    <Warning>
      `secret`을 즉시 저장하세요 — **한 번만 표시되며** 다시 조회할 수 없습니다. 모든 수신 웹훅 서명 검증에 필요합니다.
    </Warning>
  </Step>

  <Step title="엔드포인트 구현">
    웹훅 핸들러는 다음을 수행해야 합니다:

    * JSON 본문으로 `POST` 요청을 수락
    * **5초 이내**에 `2xx` 상태 코드로 응답
    * 처리 전 `X-MoriBiz-Signature` 헤더 검증
  </Step>

  <Step title="서명 검증">
    모든 요청에는 `X-MoriBiz-Signature` 헤더가 포함됩니다. 사용 중인 언어의 구현은 아래 [서명 검증](#서명-검증)을 참고하세요.
  </Step>
</Steps>

## 이벤트 유형

| 이벤트 유형                             | 설명                 |
| ---------------------------------- | ------------------ |
| `order.antiAi.completed`           | Anti-AI 처리 성공 완료   |
| `order.antiAi.failed`              | Anti-AI 처리 실패      |
| `order.watermarkEmbed.completed`   | 워터마크 삽입 성공 완료      |
| `order.watermarkEmbed.failed`      | 워터마크 삽입 실패         |
| `order.watermarkExtract.completed` | 워터마크 추출 성공 완료      |
| `order.watermarkExtract.failed`    | 워터마크 추출 실패         |
| `order.aiDetection.completed`      | AI Detection 성공 완료 |
| `order.aiDetection.failed`         | AI Detection 실패    |

## 웹훅 페이로드

모든 웹훅 페이로드는 다음 구조를 따릅니다:

```json theme={null}
{
  "eventId": "550e8400-e29b-41d4-a716-446655440000",
  "eventType": "order.antiAi.completed",
  "occurredAt": "2026-02-19T12:00:00.000Z",
  "data": {
    "orderId": "123456789",
    "orderName": "anti_ai_2026-02-19",
    "createdAt": "2026-02-19T11:50:00.000Z",
    "updatedAt": "2026-02-19T12:00:00.000Z",
    "completedAt": "2026-02-19T12:00:00.000Z",
    "status": "complete"
    // ...각 기능 별 추가 필드
  }
}
```

### 완료 이벤트 — 공통 필드

| 필드            | 타입     | 설명                       |
| ------------- | ------ | ------------------------ |
| `orderId`     | string | 주문 ID                    |
| `orderName`   | string | 주문명                      |
| `createdAt`   | string | 주문 생성 시간 (ISO 8601)      |
| `updatedAt`   | string | 주문 최종 업데이트 시간 (ISO 8601) |
| `completedAt` | string | 처리 완료 시간 (ISO 8601)      |
| `status`      | string | 항상 `complete`            |

### 실패 이벤트 — 공통 필드

| 필드             | 타입     | 설명                       |
| -------------- | ------ | ------------------------ |
| `orderId`      | string | 주문 ID                    |
| `orderName`    | string | 주문명                      |
| `createdAt`    | string | 주문 생성 시간 (ISO 8601)      |
| `updatedAt`    | string | 주문 최종 업데이트 시간 (ISO 8601) |
| `failedAt`     | string | 처리 실패 시간 (ISO 8601)      |
| `status`       | string | 항상 `failed`              |
| `errorCode`    | string | 오류 코드                    |
| `errorMessage` | string | 오류 메시지                   |

### Anti-AI / 워터마크 삽입 — 추가 필드 (완료)

| 필드            | 타입      | 설명                      |
| ------------- | ------- | ----------------------- |
| `fileCount`   | integer | 처리된 파일 수                |
| `downloadUrl` | string  | 결과 파일 다운로드 URL (1시간 유효) |

### 워터마크 추출 — 추가 필드 (완료)

`status` 필드는 항상 `complete`입니다. 워터마크 감지 여부는 `statusCode`로 확인하세요.

| 필드                   | 타입      | 설명                                          |
| -------------------- | ------- | ------------------------------------------- |
| `statusCode`         | string  | `detected` 또는 `undetected`                  |
| `watermarkFound`     | boolean | 워터마크 감지 여부                                  |
| `watermarkInfo.text` | string  | 감지된 워터마크 텍스트 (`watermarkFound`가 `true`일 때만) |

### AI Detection — 추가 필드 (완료)

| 필드            | 타입             | 설명                                                          |
| ------------- | -------------- | ----------------------------------------------------------- |
| `probability` | number         | AI 생성 확률 (0.0–1.0)                                          |
| `heatmapUrl`  | string \| null | AI 판별 히트맵 이미지 다운로드 URL (`generateHeatmap` 옵션 사용 시에만 반환)     |
| `overlayUrl`  | string \| null | 원본 위 히트맵 오버레이 이미지 다운로드 URL (`generateOverlay` 옵션 사용 시에만 반환) |

## 서명 검증

`X-MoriBiz-Signature` 헤더를 확인하여 웹훅 진위를 검증합니다. 이벤트 처리 **전에** 반드시 검증하세요.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhookSignature(rawBody, signature, secret) {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(rawBody)          // 원시 요청 바디 문자열 사용
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }

  // Express 핸들러
  app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const signature = req.headers['x-moribiz-signature'];

    if (!verifyWebhookSignature(req.body.toString(), signature, process.env.WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const { eventType, data } = JSON.parse(req.body);
    console.log(`주문 ${data.orderId}의 ${eventType} 이벤트 수신`);
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_webhook_signature(raw_body: str, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode(),
          raw_body.encode(),
          hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(signature, expected)

  # Flask 핸들러
  from flask import Flask, request, abort
  import json, os

  app = Flask(__name__)

  @app.route('/webhook', methods=['POST'])
  def webhook():
      signature = request.headers.get('X-MoriBiz-Signature')
      raw_body  = request.get_data(as_text=True)

      if not verify_webhook_signature(raw_body, signature, os.environ['WEBHOOK_SECRET']):
          abort(401)

      event = json.loads(raw_body)
      return '', 200
  ```

  ```php PHP theme={null}
  <?php
  function verifyWebhookSignature(string $rawBody, string $signature, string $secret): bool {
      $expected = hash_hmac('sha256', $rawBody, $secret);
      return hash_equals($expected, $signature);
  }

  $signature = $_SERVER['HTTP_X_MORIBIZ_SIGNATURE'] ?? '';
  $rawBody   = file_get_contents('php://input');

  if (!verifyWebhookSignature($rawBody, $signature, getenv('WEBHOOK_SECRET'))) {
      http_response_code(401);
      exit('Unauthorized');
  }

  $event = json_decode($rawBody, true);
  http_response_code(200);
  ```

  ```ruby Ruby theme={null}
  require 'openssl'
  require 'json'

  def verify_webhook_signature(raw_body, signature, secret)
    expected = OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body)
    Rack::Utils.secure_compare(expected, signature)
  end

  # Sinatra / Rails
  post '/webhook' do
    raw_body  = request.body.read
    signature = request.env['HTTP_X_MORIBIZ_SIGNATURE']

    halt 401, 'Unauthorized' unless
      verify_webhook_signature(raw_body, signature, ENV['WEBHOOK_SECRET'])

    event = JSON.parse(raw_body)
    status 200
  end
  ```

  ```go Go theme={null}
  import (
  	"crypto/hmac"
  	"crypto/sha256"
  	"encoding/hex"
  	"io"
  	"net/http"
  	"os"
  )

  func verifySignature(rawBody []byte, signature, secret string) bool {
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))
  	return hmac.Equal([]byte(expected), []byte(signature))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	body, _ := io.ReadAll(r.Body)
  	signature := r.Header.Get("X-MoriBiz-Signature")

  	if !verifySignature(body, signature, os.Getenv("WEBHOOK_SECRET")) {
  		http.Error(w, "Unauthorized", http.StatusUnauthorized)
  		return
  	}
  	w.WriteHeader(http.StatusOK)
  }
  ```

  ```java Java theme={null}
  import javax.crypto.Mac;
  import javax.crypto.spec.SecretKeySpec;
  import java.security.MessageDigest;
  import java.util.HexFormat;

  public static boolean verifySignature(String rawBody, String signature, String secret)
          throws Exception {
      Mac mac = Mac.getInstance("HmacSHA256");
      mac.init(new SecretKeySpec(secret.getBytes(), "HmacSHA256"));
      String expected = HexFormat.of().formatHex(mac.doFinal(rawBody.getBytes()));
      return MessageDigest.isEqual(expected.getBytes(), signature.getBytes());
  }

  // Spring Boot 핸들러
  @PostMapping("/webhook")
  public ResponseEntity<Void> handleWebhook(
          @RequestBody String rawBody,
          @RequestHeader("X-MoriBiz-Signature") String signature) throws Exception {

      if (!verifySignature(rawBody, signature, webhookSecret)) {
          return ResponseEntity.status(401).build();
      }
      return ResponseEntity.ok().build();
  }
  ```
</CodeGroup>

## 재시도 정책

BIZ MORI는 안정적인 웹훅 전송을 보장하기 위해 다단계 재시도 시스템을 사용합니다.

### 초기 재시도

엔드포인트가 `2xx` 상태 코드를 반환하지 않으면 BIZ MORI가 즉시 재시도합니다:

| 시도     | 지연 |
| ------ | -- |
| 1차 재시도 | 1초 |
| 2차 재시도 | 2초 |
| 3차 재시도 | 4초 |

### 스케줄러 기반 재시도

초기 재시도가 모두 실패하면, 이벤트는 점차 간격이 늘어나는 스케줄러 기반 재시도 큐에 등록됩니다:

| 시도     | 지연  |
| ------ | --- |
| 4차 재시도 | 30분 |
| 5차 재시도 | 2시간 |
| 6차 재시도 | 4시간 |

스케줄러 기반 재시도가 모두 소진되면 이벤트가 `FAILED`로 표시됩니다. [웹훅 이벤트 목록](/ko/api-reference/webhooks/list-events) 엔드포인트를 사용하여 실패한 이벤트를 확인할 수 있습니다.

### 수동 재전송 API

자동 재시도 프로세스와 관계없이, 재전송 API를 통해 언제든지 수동으로 웹훅을 재전송할 수 있습니다:

* **단일 이벤트 재전송**: [웹훅 이벤트 재전송](/api-reference/webhooks/retry-event) 엔드포인트를 사용하여 특정 실패 이벤트를 재시도합니다.
* **일괄 재전송**: [실패한 웹훅 이벤트 일괄 재전송](/api-reference/webhooks/retry-failed-events) 엔드포인트를 사용하여 특정 기간(최대 7일) 내 실패한 모든 이벤트를 재시도합니다.

```bash theme={null}
# 단일 이벤트 재전송
curl -X POST https://api.bizmori.com/api/v2/orders/webhooks/{webhookId}/events/{eventId}/retry \
  -H "Authorization: Bearer YOUR_API_TOKEN"

# 특정 기간 내 실패한 이벤트 일괄 재전송
curl -X POST https://api.bizmori.com/api/v2/orders/webhooks/{webhookId}/events/retry-failed \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "fromDate": "2026-01-01",
    "toDate": "2026-01-07"
  }'
```

### 실패 알림 이메일

초기 웹훅 전송이 실패하면 계정 소유자에게 웹훅 전송 실패 알림 이메일을 발송합니다. 과도한 알림을 방지하기 위해 이 이메일은 **하루 최대 1회**만 발송됩니다.

## 웹훅 관리

| 작업            | 엔드포인트                                                                                  |
| ------------- | -------------------------------------------------------------------------------------- |
| 웹훅 목록 조회      | [GET /webhooks](/ko/api-reference/webhooks/list-webhooks)                              |
| 웹훅 생성         | [POST /webhooks](/ko/api-reference/webhooks/create-webhook)                            |
| 웹훅 상세 조회      | [GET /webhooks/{id}](/ko/api-reference/webhooks/get-webhook)                           |
| 웹훅 수정         | [PUT /webhooks/{id}](/ko/api-reference/webhooks/update-webhook)                        |
| 웹훅 삭제         | [DELETE /webhooks/{id}](/ko/api-reference/webhooks/delete-webhook)                     |
| 전송 이벤트 조회     | [GET /webhooks/{id}/events](/ko/api-reference/webhooks/list-events)                    |
| 단일 이벤트 재전송    | [POST /webhooks/{id}/events/{eventId}/retry](/api-reference/webhooks/retry-event)      |
| 실패 이벤트 일괄 재전송 | [POST /webhooks/{id}/events/retry-failed](/api-reference/webhooks/retry-failed-events) |
