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

# 웹훅

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

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

## 웹훅 설정

**1. 웹훅 엔드포인트 등록**

API 또는 [ContentCare 대시보드](https://app.contentcare.ai/webhooks)에서 웹훅 엔드포인트를 생성할 수 있습니다. 이 예제는 live API 키로 live 엔드포인트를 생성합니다. API를 통해 생성하려면 [웹훅 생성](https://docs.contentcare.ai/ko/api-reference/webhooks/create-webhook.md) 엔드포인트를 사용합니다:

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

**2. 엔드포인트 구현**

웹훅 핸들러는 다음을 수행해야 합니다:

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

**3. 서명 검증**

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

## 테스트 웹훅

테스트 주문 이벤트를 받으려면 Dashboard, 일반 API 키 또는 테스트 API 키로 별도의 테스트 endpoint를 등록하세요. 테스트 키는 `isTest`를 생략하거나 `true`로 지정할 수 있고, `isTest: false`는 `AUTH_FORBIDDEN`을 반환합니다.

```bash theme={null}
curl -X POST https://api.bizmori.com/api/v2/orders/webhooks \
  -H "Authorization: Bearer $BIZMORI_TEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Integration test webhook",
    "url": "https://example.com/test-webhook",
    "isTest": true
  }'
```

* 테스트 API 키는 같은 owner의 테스트 endpoint만 생성·목록·상세·수정·삭제할 수 있고, 해당 endpoint의 이벤트 조회와 실패 이벤트 재전송을 할 수 있습니다. live 모드와 다른 owner의 리소스는 노출되지 않습니다.
* `isTest: true` endpoint는 테스트 주문 이벤트만 받습니다.
* `isTest: false` endpoint는 운영 주문 이벤트만 받습니다.
* 테스트 웹훅도 일반 웹훅과 동일한 이벤트명, HMAC-SHA256 서명, timeout, 재시도 정책을 사용합니다.
* 테스트 처리는 결과 파일을 만들지 않으므로 Anti-AI와 워터마크 삽입 이벤트의 `downloadUrl`은 `null`입니다.

테스트 endpoint CRUD, 이벤트 목록, 재전송에는 아래의 owner 및 테스트 모드 격리가 동일하게 적용됩니다.

## 이벤트 유형

| 이벤트 유형 | 설명 |
| - | - |
| `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 | 오류 코드에 대응하는 고정 안내 문구(한국어). 처리 서버의 원문은 포함하지 않습니다 |

### 실패 이벤트 오류 코드

`errorCode`는 항상 아래 공개 코드 중 하나입니다. 내부 처리 서버의 코드나 오류 원문은 포함하지 않습니다.

| 서비스 | `errorCode` | 의미 |
| - | - | - |
| Anti-AI | `PROCESSING_ERROR` | 처리 실패 |
| 워터마크 삽입 / 추출 | `WATERMARK_FILE_FORMAT_UNSUPPORTED` | 지원하지 않는 파일 형식 (애니메이션 이미지, 내용과 형식이 다른 파일 포함). JPEG, PNG, WEBP, BMP, TIFF 정지 이미지 또는 PDF를 사용하세요 |
| 워터마크 삽입 / 추출 | `WATERMARK_FILE_CORRUPTED` | 손상됐거나 읽을 수 없는 파일 |
| 워터마크 삽입 / 추출 | `WATERMARK_FILE_SIZE_UNSUPPORTED` | 빈 파일이거나 크기 제한 초과 |
| 워터마크 삽입 / 추출 | `WATERMARK_IMAGE_COLOR_MODE_UNSUPPORTED` | 팔레트·CMYK·16비트·흑백+투명도 등 지원하지 않는 색상 모드. RGB, 흑백 또는 투명도가 있는 RGBA(PNG·WEBP) 이미지를 사용하세요 |
| 워터마크 삽입 / 추출 | `WATERMARK_IMAGE_DIMENSIONS_UNSUPPORTED` | 지원하지 않는 이미지 해상도 또는 방향 |
| 워터마크 삽입 / 추출 | `WATERMARK_PDF_UNSUPPORTED` | 처리할 수 없는 PDF (암호화, 페이지 수 초과, 손상 등) |
| 워터마크 삽입 / 추출 | `WATERMARK_INPUT_UNSUPPORTED` | 그 밖에 워터마크 엔진이 처리할 수 없는 입력 (불투명 영역이 없거나 너무 작거나 고르지 않은 투명 배경 이미지 포함) |
| 워터마크 삽입 / 추출 | `WATERMARK_ENGINE_UNAVAILABLE` | 워터마크 엔진 일시 오류. 같은 파일로 다시 시도하세요 |
| 워터마크 삽입 / 추출 | `ORDER_TIMEOUT` | 처리 시간 초과. 다시 시도하세요 |
| 워터마크 삽입 / 추출 | `UPLOAD_EXPIRED` | 업로드 대기 시간 안에 파일이 업로드되지 않음. 새 주문을 만드세요 |
| 워터마크 삽입 / 추출 | `WATERMARK_V2_API_SUNSET` | v2 지원 종료(2026년 10월 9일 09:00 KST) 시점까지 처리를 시작하지 않은 v2 주문. 과금되지 않고 크레딧은 복원됨. v3로 새 주문을 만드세요 |
| 워터마크 삽입 / 추출 | `PROCESSING_ERROR` | 그 밖의 처리 실패 |
| AI Detection | `AI_DETECTION_INVALID_IMAGE` | 이미지를 읽을 수 없음 |
| AI Detection | `AI_DETECTION_TIMEOUT` | 판별 시간 초과. 다시 시도하세요 |
| AI Detection | `AI_DETECTION_SERVICE_ERROR` | 판별 서비스 일시 오류. 다시 시도하세요 |
| AI Detection | `AI_DETECTION_FAILED` | 그 밖의 판별 실패 |

테스트 주문은 항상 `PROCESSING_ERROR`입니다. 새 코드가 추가돼도 연동이 깨지지 않도록, 모르는 코드는 일반 실패로 처리하세요.

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

| 필드 | 타입 | 설명 |
| - | - | - |
| `fileCount` | integer | 처리된 파일 수 |
| `downloadUrl` | string \| null | 결과 파일 다운로드 URL (운영 주문은 7일 유효, 테스트 주문은 `null`) |

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

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

| 필드 | 타입 | 설명 |
| - | - | - |
| `statusCode` | string | `detected` 또는 `undetected`. 새 워터마크 엔진 주문은 `inconclusive`(결과 확정 불가), `unsupported`(검사할 수 없는 파일), `error`(검출 오류)도 반환할 수 있습니다 |
| `watermarkFound` | boolean | 워터마크 감지 여부 |
| `watermarkInfo.text` | string | 감지된 워터마크 텍스트 (`watermarkFound`가 `true`일 때만) |

페이로드는 텍스트만 공개하며 MID, `midDecimal`, `midHex`는 포함하지 않습니다.

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

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

`heatmapUrl`과 `overlayUrl`은 긴 변을 1600px 이내로 줄인 WebP 이미지를 가리킵니다. 주문이 완료되는 시점에 리사이즈 이미지가 준비되지 않았으면 원본 크기 PNG 파일을 가리킵니다. 웹훅 payload는 주문 완료 시점에 확정되므로, 주문 상세에서는 나중에 리사이즈 이미지가 반환될 수 있습니다.

## 서명 검증

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

```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();
}
```

## 재시도 정책

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

### 초기 재시도

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

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

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

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

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

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

### 수동 재전송 API

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

* **단일 이벤트 재전송**: `POST /api/v2/orders/webhooks/{webhookId}/events/{eventId}/retry`를 사용하여 특정 실패 이벤트를 재시도합니다.
* **일괄 재전송**: `POST /api/v2/orders/webhooks/{webhookId}/events/retry-failed`를 사용하여 특정 기간(최대 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회**만 발송됩니다.

## 웹훅 관리

## 테스트 API 키 범위

테스트 API 키는 같은 owner의 테스트 엔드포인트만 관리할 수 있습니다. 해당 엔드포인트의 목록·상세·등록·수정·삭제와 이벤트 목록, 실패한 단일 이벤트 재전송, 최대 7일 범위의 실패 이벤트 일괄 재전송을 할 수 있습니다. 이벤트 응답에는 전송 로그의 응답 본문이 추가되지 않습니다.

* 등록 시 `isTest`를 생략하거나 `true`로 지정하면 테스트 엔드포인트가 생성됩니다. `isTest: false`는 `AUTH_FORBIDDEN`을 반환합니다.
* 수정 시 `isTest`를 생략하면 테스트 모드를 유지하고, `true`는 허용됩니다. `isTest: false`는 `AUTH_FORBIDDEN`을 반환합니다.
* live 엔드포인트 ID 또는 다른 owner의 엔드포인트 ID는 `WEBHOOK_NOT_FOUND`를 반환하며, 존재 여부를 공개하지 않습니다.
* 접근 가능한 엔드포인트에 속하지 않은 이벤트 ID는 `WEBHOOK_EVENT_NOT_FOUND`를 반환합니다.

live API 키와 Dashboard는 기존처럼 `isTest`를 선택하고 owner의 live·test 엔드포인트를 관리할 수 있습니다. secret 일회성 표시, URL 검증, HMAC 서명, 재시도 정책은 변경되지 않습니다.

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


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.