> ## 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.

# 비가시성 워터마크 검출

> v3 API로 파일에서 비가시성 워터마크 검출하기

이 가이드는 v3 API로 파일에 비가시성 워터마크가 있는지 확인하는 과정을 주문 생성부터 결과 확인, PDF 보고서 다운로드까지 안내합니다.

모든 엔드포인트는 순수 HTTPS와 JSON을 사용하므로 서버에서든 브라우저에서든 똑같이 호출할 수 있습니다. 각 단계에는 **React** 탭이 있고, 그대로 복사해 쓸 수 있는 [전체 컴포넌트](#전체-react-예제)는 페이지 하단에 있습니다.

> [!WARNING]
>
> v2 워터마크 검출 엔드포인트(`POST /api/v2/orders/wtr-extract`)는 2026년 10월 9일 09:00 KST(00:00 UTC)에 지원이 종료되었습니다. 이 가이드는 v3 API를 기준으로 설명합니다. 이미 v2를 사용하고 있다면 [v2에서 v3로 마이그레이션](#v2에서-v3로-마이그레이션)을 참고하세요.

> [!WARNING]
>
> **v3 워터마크 검출은 v2로 삽입한 워터마크를 검출할 수 없습니다.** 엔드포인트만 v3로 바꿔도 기존 v2 워터마크 이미지는 검출되지 않으며, v2 지원이 종료된 뒤 v2 검출을 위한 예외도 제공하지 않습니다.

## 사전 준비

* BIZ MORI API 키 ([여기서 발급](https://app.contentcare.ai/keys))
* 워터마크를 삽입했던 **원본 파일** — 워터마크가 삽입된 결과물이 아니라 [워터마크 삽입](https://docs.contentcare.ai/ko/quickstart/watermark-embed.md) 때 보낸 파일입니다
* **검사할 파일**:
  * 원본이 이미지(`jpg`, `jpeg`, `png`, `webp`, `bmp`, `tif`, `tiff`)라면 검사할 파일도 이미지여야 합니다.
  * 원본이 PDF라면 검사할 파일은 PDF이거나, 그 PDF를 캡처한 PNG·JPEG 이미지입니다.

> [!NOTE]
>
> 자동 발급된 [`sk_test_` 키](https://docs.contentcare.ai/ko/test-api-keys)를 사용하면 실제 워터마크 검출이나 크레딧 사용 없이 v3 흐름 전체를 검증할 수 있습니다. 테스트 업로드는 파일 내용을 폐기하며, 결과는 검사할 파일의 이름으로 정해집니다. `_detected`가 있으면 `detected`, 그 밖에는 `undetected`이고, 두 파일 중 하나라도 이름에 `_fail`이 있으면 주문이 실패합니다.

## 동작 방식

1. 원본 파일과 검사할 파일의 이름으로 **주문을 생성**합니다.
2. 응답의 프리사인드 URL로 **두 파일을 `PUT` 업로드**합니다. 두 파일이 모두 업로드되면 검출이 자동으로 시작되며, confirm 단계는 없습니다.
3. `GET /api/v3/orders/{orderId}`를 폴링하거나 [웹훅](https://docs.contentcare.ai/ko/webhooks.md)을 받아 **결과를 확인**합니다.
4. 필요하면 결과에 대한 **PDF 보고서를 생성**합니다.

## 1단계: 주문 생성

워터마크 검출 주문을 생성합니다. 주문 하나는 파일 하나를 검사하며 1크레딧을 사용합니다.

```bash cURL theme={null}
ORDER_IDEMPOTENCY_KEY=$(uuidgen | tr '[:upper:]' '[:lower:]')
curl -X POST https://api.bizmori.com/api/v3/orders/wtr-extract \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "'$ORDER_IDEMPOTENCY_KEY'",
    "file": {
      "fileName": "suspect.png",
      "originalFile": { "fileName": "campaign.png" }
    }
  }'
```

```javascript Node.js theme={null}
import { randomUUID } from 'node:crypto';

const API_BASE = 'https://api.bizmori.com/api/v3';
const headers = { Authorization: 'Bearer YOUR_API_KEY' };

const response = await fetch(`${API_BASE}/orders/wtr-extract`, {
  method: 'POST',
  headers: { ...headers, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    idempotencyKey: randomUUID(),
    file: {
      fileName: 'suspect.png',
      originalFile: { fileName: 'campaign.png' },
    },
  }),
});
const { data: order } = await response.json();
```

```jsx React theme={null}
// JSON을 사용하는 모든 호출에 쓰는 헬퍼 함수 하나입니다. API는 실패 시
// `{ code: "ERROR_CODE" }`를 반환하므로, 단순 상태 코드 대신 이 code를 노출합니다.
const API_BASE = 'https://api.bizmori.com/api/v3';
const API_KEY = import.meta.env.VITE_MORI_API_KEY;

async function api(path, { method = 'GET', body, idempotencyKey } = {}) {
  const res = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      ...(body && { 'Content-Type': 'application/json' }),
    },
    body: body && JSON.stringify({ idempotencyKey, ...body }),
  });

  if (!res.ok) {
    const { code } = await res.json().catch(() => ({}));
    throw new Error(code ?? `HTTP_${res.status}`);
  }
  return (await res.json()).data;
}

// `originalFile`과 `checkedFile`은 사용자가 선택한 두 File입니다
const order = await api('/orders/wtr-extract', {
  method: 'POST',
  idempotencyKey: crypto.randomUUID(),
  body: {
    file: {
      fileName: checkedFile.name,
      originalFile: { fileName: originalFile.name },
    },
  },
});
```

```python Python theme={null}
import uuid
import requests

API_BASE = 'https://api.bizmori.com/api/v3'
HEADERS = {'Authorization': 'Bearer YOUR_API_KEY'}

res = requests.post(
    f'{API_BASE}/orders/wtr-extract',
    headers=HEADERS,
    json={
        'idempotencyKey': str(uuid.uuid4()),
        'file': {
            'fileName': 'suspect.png',
            'originalFile': {'fileName': 'campaign.png'},
        },
    },
)
order = res.json()['data']
```

**응답:**

```json theme={null}
{
  "data": {
    "orderId": "1234567891",
    "status": "pending",
    "processingDelayed": false,
    "expiresAt": "2026-10-10T03:00:00.000Z",
    "files": [
      {
        "fileId": "601",
        "uploadUrl": "https://s3.ap-northeast-2.amazonaws.com/..."
      },
      {
        "fileId": "602",
        "uploadUrl": "https://s3.ap-northeast-2.amazonaws.com/..."
      }
    ],
    "outputs": [],
    "result": null,
    "reports": []
  }
}
```

`files`에는 항상 두 항목이 있습니다. **`files[0]`은 원본 파일**, **`files[1]`은 검사할 파일**입니다. 주문 상세에서는 같은 파일이 `inputFiles[]`에 `role`이 `original`, `query`인 항목으로 나옵니다.

> [!NOTE]
>
> 같은 `idempotencyKey`는 같은 요청을 재시도할 때만 다시 사용하세요. 이 경우 새 주문을 만들지 않고 기존 주문을 반환합니다. 같은 키로 요청 본문이 다른 요청을 보내면 `409 WATERMARK_V2_CONFLICT`를 반환합니다.

## 2단계: 두 파일 업로드

각 파일을 프리사인드 `uploadUrl`로 PUT 업로드합니다. 스토리지로 직접 올리는 업로드이므로 **Authorization 헤더는 필요 없습니다**.

`Content-Type` 헤더는 파일 확장자에 맞는 값으로 보내야 합니다. 프리사인드 URL이 이 타입으로 서명되어 있어 다른 값을 보내면 업로드가 거부됩니다.

| 확장자 | `Content-Type` |
| - | - |
| `jpg`, `jpeg` | `image/jpeg` |
| `png` | `image/png` |
| `webp` | `image/webp` |
| `bmp` | `image/bmp` |
| `tif`, `tiff` | `image/tiff` |
| `pdf` | `application/pdf` |

```bash cURL theme={null}
# files[0]: 원본 파일
curl -X PUT "https://s3.ap-northeast-2.amazonaws.com/...original..." \
  -H "Content-Type: image/png" \
  --data-binary @campaign.png

# files[1]: 검사할 파일
curl -X PUT "https://s3.ap-northeast-2.amazonaws.com/...checked..." \
  -H "Content-Type: image/png" \
  --data-binary @suspect.png
```

```javascript Node.js theme={null}
import { readFile } from 'node:fs/promises';

async function putFile(uploadUrl, path, contentType) {
  const res = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': contentType },
    body: await readFile(path),
  });
  if (!res.ok) throw new Error(`UPLOAD_FAILED_${res.status}`);
}

await putFile(order.files[0].uploadUrl, 'campaign.png', 'image/png'); // 원본
await putFile(order.files[1].uploadUrl, 'suspect.png', 'image/png'); // 검사할 파일
```

```jsx React theme={null}
// 프리사인드 URL은 파일 확장자에 맞는 타입으로 서명되어 있으므로,
// `file.type` 대신 파일 이름에서 Content-Type을 정합니다.
const CONTENT_TYPES = {
  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
  bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', pdf: 'application/pdf',
};
const contentTypeOf = (name) => CONTENT_TYPES[name.split('.').pop().toLowerCase()];

// fetch()는 업로드 진행률을 알려줄 수 없으므로, PUT에는 XMLHttpRequest를 사용합니다.
function putFile(file, uploadUrl, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', uploadUrl);
    xhr.setRequestHeader('Content-Type', contentTypeOf(file.name));

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    xhr.onload = () =>
      xhr.status < 300 ? resolve() : reject(new Error(`UPLOAD_FAILED_${xhr.status}`));
    xhr.onerror = () => reject(new Error('UPLOAD_NETWORK_ERROR'));

    xhr.send(file);
  });
}

// files[0]은 원본, files[1]은 검사할 파일입니다.
await Promise.all([
  putFile(originalFile, order.files[0].uploadUrl, () => {}),
  putFile(checkedFile, order.files[1].uploadUrl, (ratio) => setProgress(ratio)),
]);
```

```python Python theme={null}
def put_file(upload_url, path, content_type):
    with open(path, 'rb') as f:
        res = requests.put(upload_url, headers={'Content-Type': content_type}, data=f)
    res.raise_for_status()

put_file(order['files'][0]['uploadUrl'], 'campaign.png', 'image/png')  # 원본
put_file(order['files'][1]['uploadUrl'], 'suspect.png', 'image/png')   # 검사할 파일
```

> [!NOTE]
>
> **confirm 단계는 필요 없습니다.** 두 파일이 모두 업로드되면 검출이 자동으로 시작됩니다.

> [!NOTE]
>
> 일반 키의 `uploadUrl`은 **900초** 뒤에 만료됩니다. 만료되었다면 [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/ko/api-reference/orders/get-order-v3)를 호출해 아직 업로드되지 않은 파일의 새 `inputFiles[].uploadUrl`을 받으세요. 주문 생성 후 **12시간** 안에 두 파일을 모두 업로드하지 않으면 주문이 `failed`가 됩니다. 테스트 키는 1시간 동안 유효한 `https://api.bizmori.com/api/v2/test-uploads/{signedToken}` URL을 받습니다. 인증 없이 같은 방식으로 PUT하면 되며, 업로드한 파일 내용은 폐기됩니다.

## 3단계: 결과 확인

`status`가 `complete`가 될 때까지 주문을 폴링한 뒤 `result`를 읽으세요. 폴링 대신 [웹훅](https://docs.contentcare.ai/ko/webhooks.md)을 설정하면 `order.watermarkExtract.completed` 또는 `order.watermarkExtract.failed` 이벤트를 받을 수 있습니다.

```bash cURL theme={null}
curl https://api.bizmori.com/api/v3/orders/1234567891 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```javascript Node.js theme={null}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function waitForOrder(orderId) {
  for (let delay = 2000; ; delay = Math.min(delay * 1.5, 15000)) {
    const res = await fetch(`${API_BASE}/orders/${orderId}`, { headers });
    const { data } = await res.json();
    if (['complete', 'failed', 'expired'].includes(data.status)) return data;
    await sleep(delay);
  }
}

const finished = await waitForOrder(order.orderId);
console.log(finished.result?.detectionStatus, finished.result?.watermarkText);
```

```jsx React theme={null}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// 대기 시간이 길어질수록 폴링 간격도 늘립니다: 빠른 응답은 빠르게 유지하고,
// 오래 걸리는 작업이 API를 계속 두드리지 않도록 합니다. 무한정 폴링하지 않고 10분 후 포기합니다.
// 폴링은 `status`로 판단하고, 검출 결과는 `result`에서 읽습니다.
async function pollOrder(orderId, { onStatus, timeoutMs = 10 * 60 * 1000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let delay = 2000;

  while (Date.now() < deadline) {
    const order = await api(`/orders/${orderId}`);
    onStatus?.(order.status);
    if (['complete', 'failed', 'expired'].includes(order.status)) return order;

    await sleep(delay);
    delay = Math.min(delay * 1.5, 15000);
  }
  throw new Error('ORDER_POLL_TIMEOUT');
}
```

```python Python theme={null}
import time

def wait_for_order(order_id):
    delay = 2
    while True:
        res = requests.get(f'{API_BASE}/orders/{order_id}', headers=HEADERS)
        data = res.json()['data']
        if data['status'] in ('complete', 'failed', 'expired'):
            return data
        time.sleep(delay)
        delay = min(delay * 1.5, 15)

finished = wait_for_order(order['orderId'])
```

**응답 (워터마크 검출됨):**

```json theme={null}
{
  "data": {
    "type": "watermarkExtract",
    "orderId": "1234567891",
    "orderName": "suspect.png",
    "channel": "api",
    "status": "complete",
    "createdAt": "2026-10-03T03:00:00.000Z",
    "updatedAt": "2026-10-03T03:00:40.000Z",
    "expiresAt": "2026-10-10T03:00:00.000Z",
    "retentionDays": 7,
    "thumbnailImageUrl": "https://...",
    "processingDelayed": false,
    "errors": null,
    "inputFiles": [
      { "fileId": "601", "fileName": "campaign.png", "role": "original" },
      { "fileId": "602", "fileName": "suspect.png", "role": "query" }
    ],
    "result": {
      "detectionStatus": "detected",
      "watermarkText": "PARTNER-A",
      "reportSupported": true,
      "reports": []
    }
  }
}
```

`status`로 주문이 끝났는지 확인한 뒤, `result.detectionStatus`로 결과를 읽으세요.

| `status` | 의미 |
| - | - |
| `pending` | 파일 업로드 대기 중 |
| `inProgress` | 검출 처리 중 |
| `complete` | 완료 — `result`를 확인하세요 |
| `failed` | 검출 실패. 사용한 크레딧은 복구됩니다 |
| `expired` | 보관 기간이 끝나 파일이 삭제됨 |

| `result.detectionStatus` | 의미 |
| - | - |
| `detected` | 워터마크를 찾음 |
| `undetected` | 파일 전체를 검사했지만 워터마크가 없음 |
| `inconclusive` | 후보는 있지만 판단할 만큼 확신할 수 없음 |
| `unsupported` | 처리할 수 없는 입력. 크레딧은 복구됩니다 |
| `error` | 검출 처리 실패. "워터마크 없음"과는 다릅니다. 크레딧은 복구됩니다 |

* `result.watermarkText`는 삽입했던 문구입니다. 결과가 `detected`이고, 내 계정이 직접 삽입했으며, 삽입 기록이 정확히 1건일 때만 반환됩니다. 그 밖에는 `null`입니다.
* `result.reportSupported`는 이 결과로 PDF 보고서를 만들 수 있는지를 나타냅니다. PDF를 캡처한 이미지를 검사했다면 `false`입니다.
* `result.reports[]`에는 이 주문에 요청한 보고서별 상태가 들어 있습니다.
* `processingDelayed`가 `true`이면 자동 재시도가 모두 소진되어 수동 복구를 기다리는 중입니다. 주문은 `inProgress` 상태로 유지되므로 계속 폴링하거나 웹훅을 기다리세요.

> [!NOTE]
>
> 웹훅 페이로드는 v2의 필드 이름을 그대로 씁니다. `order.watermarkExtract.completed`는 결과를 `result.detectionStatus`와 같은 값의 `statusCode`로 알려주며, `watermarkFound`와 `watermarkInfo.text`도 함께 전달됩니다. 전체 페이로드는 [웹훅](https://docs.contentcare.ai/ko/webhooks.md)을 참고하세요.

## 4단계: PDF 보고서 생성 (선택)

`result.reportSupported`가 `true`이면 결과에 대한 PDF 보고서를 한국어(`ko-KR`), 영어(`en-US`), 일본어(`ja-JP`)로 만들 수 있습니다. PDF 파일의 보고서는 `ko-KR`만 지원합니다. 보고서는 1크레딧을 사용하며, 생성에 성공했을 때만 차감됩니다.

보고서를 요청한 뒤 `status`가 `completed`가 될 때까지 폴링하세요.

```bash cURL theme={null}
curl -X POST https://api.bizmori.com/api/v3/orders/1234567891/reports \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "locale": "ko-KR" }'

curl https://api.bizmori.com/api/v3/orders/1234567891/reports/ko-KR \
  -H "Authorization: Bearer YOUR_API_KEY"
```

```javascript Node.js theme={null}
async function createReport(orderId, locale) {
  const created = await fetch(`${API_BASE}/orders/${orderId}/reports`, {
    method: 'POST',
    headers: { ...headers, 'Content-Type': 'application/json' },
    body: JSON.stringify({ locale }),
  });
  if (!created.ok) throw new Error((await created.json()).code);

  for (let delay = 2000; ; delay = Math.min(delay * 1.5, 15000)) {
    const res = await fetch(`${API_BASE}/orders/${orderId}/reports/${locale}`, { headers });
    const { data: report } = await res.json();
    if (report.status === 'completed') return report.download.url;
    if (report.status === 'failed') throw new Error(report.failure.code);
    await sleep(delay);
  }
}

const reportUrl = await createReport(order.orderId, 'ko-KR');
```

```jsx React theme={null}
// 보고서 URL은 300초 뒤 만료됩니다. 사용자가 다운로드를 누를 때마다
// GET 엔드포인트를 다시 호출해 받은 새 URL로 이동하세요.
async function openReport(orderId, locale) {
  await api(`/orders/${orderId}/reports`, { method: 'POST', body: { locale } });

  for (let delay = 2000; ; delay = Math.min(delay * 1.5, 15000)) {
    const report = await api(`/orders/${orderId}/reports/${locale}`);
    if (report.status === 'completed') return window.location.assign(report.download.url);
    if (report.status === 'failed') throw new Error(report.failure.code);
    await sleep(delay);
  }
}
```

```python Python theme={null}
def create_report(order_id, locale):
    created = requests.post(
        f'{API_BASE}/orders/{order_id}/reports',
        headers=HEADERS,
        json={'locale': locale},
    )
    if not created.ok:
        raise RuntimeError(created.json()['code'])
    delay = 2
    while True:
        res = requests.get(f'{API_BASE}/orders/{order_id}/reports/{locale}', headers=HEADERS)
        report = res.json()['data']
        if report['status'] == 'completed':
            return report['download']['url']
        if report['status'] == 'failed':
            raise RuntimeError(report['failure']['code'])
        time.sleep(delay)
        delay = min(delay * 1.5, 15)

report_url = create_report(order['orderId'], 'ko-KR')
```

**응답 (생성 완료):**

```json theme={null}
{
  "data": {
    "orderId": "1234567891",
    "reportType": "watermarkDetection",
    "locale": "ko-KR",
    "status": "completed",
    "download": {
      "url": "https://s3.ap-northeast-2.amazonaws.com/...",
      "expiresInSeconds": 300
    },
    "failure": null
  }
}
```

`download.url`은 **300초** 뒤 만료됩니다. 저장해 두지 말고, 보고서를 내려받을 때마다 `GET /api/v3/orders/{orderId}/reports/{locale}`을 다시 호출하세요. 보고서가 `failed`이고 `failure.retryable`이 `true`이면 POST 엔드포인트를 다시 호출해 재시도할 수 있습니다.

> [!NOTE]
>
> 테스트 키에서는 결과가 `detected`인 테스트 주문에만 보고서를 만들 수 있습니다. 요청 즉시 `completed`와 1페이지짜리 샘플 PDF를 반환하며 크레딧은 사용하지 않습니다.

## 전체 React 예제

위 내용을 모두 하나의 컴포넌트로 연결했습니다: 파일 선택 두 개, 진행률을 보여주는 업로드, 백오프 폴링, `detectionStatus`로 렌더링하는 결과, 보고서 버튼까지. React 외에 별도의 의존성은 없습니다.

```jsx WatermarkExtractChecker.jsx theme={null}
import { useRef, useState } from 'react';

const API_BASE = 'https://api.bizmori.com/api/v3';
const API_KEY = import.meta.env.VITE_MORI_API_KEY;

const CONTENT_TYPES = {
  jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp',
  bmp: 'image/bmp', tif: 'image/tiff', tiff: 'image/tiff', pdf: 'application/pdf',
};
const ACCEPT_FORMATS = Object.keys(CONTENT_TYPES).map((ext) => `.${ext}`).join(',');
const REPORT_LOCALE = 'ko-KR';

const RESULT_LABELS = {
  detected: '워터마크가 검출되었습니다',
  undetected: '이 파일에서 워터마크를 찾지 못했습니다.',
  inconclusive: '워터마크로 보이는 신호가 있지만 판단할 수 없습니다.',
  unsupported: '검사할 수 없는 파일입니다. 크레딧은 복구되었습니다.',
  error: '검출에 실패했습니다. 크레딧은 복구되었으니 다시 시도하세요.',
};

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const contentTypeOf = (name) => CONTENT_TYPES[name.split('.').pop().toLowerCase()];

async function api(path, { method = 'GET', body, idempotencyKey } = {}) {
  // 재시도는 호출자의 idempotency key를 그대로 재사용하므로, 재시도된 생성 요청이
  // 두 번째 주문을 만들어내는 일은 없습니다. 인증 실패는 `AUTH_*` 코드로 나타나며,
  // 재시도해도 소용없으므로 아래 throw로 넘어갑니다.
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(`${API_BASE}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        ...(body && { 'Content-Type': 'application/json' }),
      },
      body: body && JSON.stringify({ idempotencyKey, ...body }),
    });

    if (!res.ok) {
      const { code } = await res.json().catch(() => ({}));
      // 429는 두 경우를 포함합니다: 일시적인 레이트 리밋, 그리고 요금제 사용량이 소진되어
      // 재시도해도 풀리지 않는 PLAN_LIMIT_EXCEEDED.
      if (res.status === 429 && code !== 'PLAN_LIMIT_EXCEEDED' && attempt < 3) {
        await sleep(2 ** attempt * 1000);
        continue;
      }
      throw new Error(code ?? `HTTP_${res.status}`);
    }
    return (await res.json()).data;
  }
}

function putFile(file, uploadUrl, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('PUT', uploadUrl);
    xhr.setRequestHeader('Content-Type', contentTypeOf(file.name));

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress(event.loaded / event.total);
    };
    xhr.onload = () =>
      xhr.status < 300 ? resolve() : reject(new Error(`UPLOAD_FAILED_${xhr.status}`));
    xhr.onerror = () => reject(new Error('UPLOAD_NETWORK_ERROR'));

    xhr.send(file);
  });
}

async function poll(fetchOnce, isDone, { timeoutMs = 10 * 60 * 1000 } = {}) {
  const deadline = Date.now() + timeoutMs;
  let delay = 2000;

  while (Date.now() < deadline) {
    const value = await fetchOnce();
    if (isDone(value)) return value;

    await sleep(delay);
    delay = Math.min(delay * 1.5, 15000);
  }
  throw new Error('POLL_TIMEOUT');
}

export default function WatermarkExtractChecker() {
  const [originalFile, setOriginalFile] = useState(null);
  const [checkedFile, setCheckedFile] = useState(null);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState('idle');
  const [order, setOrder] = useState(null);
  const [error, setError] = useState(null);
  const inFlight = useRef(false);

  const busy = status !== 'idle' && status !== 'complete';
  const canSubmit =
    originalFile !== null &&
    checkedFile !== null &&
    contentTypeOf(originalFile.name) !== undefined &&
    contentTypeOf(checkedFile.name) !== undefined &&
    !busy;

  async function checkWatermark(event) {
    event.preventDefault();
    if (inFlight.current || !canSubmit) return;
    inFlight.current = true;

    // 선택 값을 고정해 둡니다. 요청이 진행되는 동안 사용자가 파일을 바꿀 수 있지만,
    // 각 파일은 자신의 업로드 URL과 짝을 유지해야 합니다.
    const original = originalFile;
    const checked = checkedFile;
    setError(null);
    setOrder(null);
    setProgress(0);

    try {
      setStatus('creating order');
      const created = await api('/orders/wtr-extract', {
        method: 'POST',
        idempotencyKey: crypto.randomUUID(),
        body: { file: { fileName: checked.name, originalFile: { fileName: original.name } } },
      });
      setOrder(created);

      setStatus('uploading');
      // files[0]은 원본, files[1]은 검사할 파일입니다.
      await Promise.all([
        putFile(original, created.files[0].uploadUrl, () => {}),
        putFile(checked, created.files[1].uploadUrl, setProgress),
      ]);

      const finished = await poll(
        () => api(`/orders/${created.orderId}`),
        (current) => {
          setStatus(current.status);
          return ['complete', 'failed', 'expired'].includes(current.status);
        },
      );
      if (finished.status !== 'complete') throw new Error(`ORDER_${finished.status.toUpperCase()}`);

      setOrder(finished);
      setStatus('complete');
    } catch (caught) {
      setError(caught.message);
      setStatus('idle');
    } finally {
      inFlight.current = false;
    }
  }

  // 보고서 URL은 300초 뒤 만료되므로 누를 때마다 새로 받습니다.
  async function openReport() {
    try {
      await api(`/orders/${order.orderId}/reports`, { method: 'POST', body: { locale: REPORT_LOCALE } });
      const report = await poll(
        () => api(`/orders/${order.orderId}/reports/${REPORT_LOCALE}`),
        (current) => ['completed', 'failed'].includes(current.status),
      );
      if (report.status === 'failed') throw new Error(report.failure.code);
      window.location.assign(report.download.url);
    } catch (caught) {
      setError(caught.message);
    }
  }

  const result = status === 'complete' ? order?.result : null;

  return (
    <form onSubmit={checkWatermark}>
      <label>
        원본 파일
        <input
          type="file"
          accept={ACCEPT_FORMATS}
          disabled={busy}
          onChange={(event) => setOriginalFile(event.target.files[0] ?? null)}
        />
      </label>
      <label>
        검사할 파일
        <input
          type="file"
          accept={ACCEPT_FORMATS}
          disabled={busy}
          onChange={(event) => setCheckedFile(event.target.files[0] ?? null)}
        />
      </label>
      <button type="submit" disabled={!canSubmit}>
        워터마크 확인
      </button>

      {status !== 'idle' && <p>상태: {status}</p>}
      {status === 'uploading' && <p>업로드 중: {Math.round(progress * 100)}%</p>}

      {/* `status`는 주문이 끝났는지, `result.detectionStatus`는 무엇을 찾았는지 알려줍니다. */}
      {result && (
        <p>
          {RESULT_LABELS[result.detectionStatus]}
          {result.watermarkText && <strong> {result.watermarkText}</strong>}
        </p>
      )}
      {result?.reportSupported && (
        <button type="button" onClick={openReport}>
          PDF 보고서 다운로드
        </button>
      )}
      {error && <p role="alert">실패: {error}{order && ` (orderId: ${order.orderId})`}</p>}
    </form>
  );
}
```

> [!NOTE]
>
> `import.meta.env.VITE_MORI_API_KEY`는 Vite 문법입니다. Next.js에서는 `process.env.NEXT_PUBLIC_MORI_API_KEY`를, 그 밖의 번들러에서는 클라이언트 코드에 노출하는 방식을 사용하세요. `crypto.randomUUID()`는 보안 컨텍스트가 필요합니다. HTTPS와 `localhost`에서는 동작하지만 일반 HTTP로 접속한 LAN 주소에서는 동작하지 않습니다.

공개 결과에는 `detectionStatus`, `reportSupported`, `watermarkText`만 포함되며 워터마크 ID(MID)는 포함되지 않습니다.

## 에러 처리

| HTTP 상태 코드 | 의미 | 조치 |
| - | - | - |
| `400` | 잘못된 요청, `REPORT_LOCALE_UNSUPPORTED`, 또는 보관 기간이 지난 `ORDER_EXPIRED` | 파라미터, 파일 확장자, 보고서 locale을 확인하세요 |
| `401` | 인증 실패 | API 키 확인 |
| `404` | `ORDER_NOT_FOUND`, 또는 요청한 적 없는 locale의 `REPORT_NOT_FOUND` | 주문 ID를 확인하고, 보고서는 생성한 뒤 조회하세요 |
| `409` | 이미 사용한 `idempotencyKey`의 `WATERMARK_V2_CONFLICT`, 또는 보고서를 만들 수 없는 결과의 `REPORT_NOT_AVAILABLE` | 새 `idempotencyKey`를 사용하거나 `result.reportSupported`를 확인하세요 |
| `429` | 레이트 리밋, 또는 요금제 사용량 소진 시 `PLAN_LIMIT_EXCEEDED` | `code` 값을 확인하세요. 레이트 리밋은 재시도로 풀리지만 `PLAN_LIMIT_EXCEEDED`는 풀리지 않으므로 요금제를 업그레이드해야 합니다 |
| `503` | 워터마크 처리 또는 보고서 생성을 일시적으로 사용할 수 없음 | 잠시 후 다시 시도하세요 |

전체 에러 코드는 [에러 코드](https://docs.contentcare.ai/ko/errors.md) 페이지를 참고하세요.

## v2에서 v3로 마이그레이션

v3는 검사할 파일을 항상 원본과 비교하므로, 요청에 두 파일을 모두 지정합니다.

> [!WARNING]
>
> **v3 워터마크 검출은 v2로 삽입한 워터마크를 검출할 수 없습니다.** 엔드포인트만 v3로 바꿔도 기존 v2 워터마크 이미지는 검출되지 않으며, v2 지원이 종료된 뒤 v2 검출을 위한 예외도 제공하지 않습니다.

| | v2 | v3 |
| - | - | - |
| 주문 생성 | `POST /api/v2/orders/wtr-extract` | `POST /api/v3/orders/wtr-extract` |
| 원본 파일 | 선택(`includeOriginal`), 이미지만 지원 | 모든 주문에 필수, 이미지와 PDF 모두 지원 |
| 요청 본문 | `file.fileName`, `file.includeOriginal`, `file.originalFile` | `file.fileName`, `file.originalFile.fileName`. `includeOriginal`은 없음 |
| 업로드 | 파일 1개, `includeOriginal` 사용 시 2개 | 항상 2개: `files[0]`은 원본, `files[1]`은 검사할 파일 |
| 결과 조회 | `GET /api/v2/orders/{orderId}` (deprecated) → `statusCode`(`detected` / `undetected`) | [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/ko/api-reference/orders/get-order-v3) → `result.detectionStatus`(5가지 값), `result.watermarkText`, `result.reportSupported` |
| 크레딧 | — | 이미지·PDF 구분 없이 주문당 1크레딧. `unsupported`·`error` 결과는 복구 |
| PDF 보고서 | 없음 | 보고서 [생성](https://docs.contentcare.ai/ko/api-reference/reports/create-report.md)·[조회](https://docs.contentcare.ai/ko/api-reference/reports/get-report.md) |
| confirm 단계 | 없음 | 없음 |
| 웹훅 | `order.watermarkExtract.completed` / `failed` | 이벤트 이름과 `data` 필드 동일. `statusCode`에 `inconclusive`, `unsupported`, `error`가 추가됨 |

> [!WARNING]
>
> 주문 조회는 v3 엔드포인트를 사용하세요. `GET /api/v3/orders/{orderId}`는 v2·v3로 만든 주문 모두에 같은 형식으로 동작합니다. deprecated된 `GET /api/v2/orders/{orderId}`는 v3 주문의 공통 필드만 반환하며 `result`는 포함하지 않습니다.

**v2 지원 종료 후 동작** (2026년 10월 9일 09:00 KST, 00:00 UTC부터):

* 인증·권한 검사는 지금처럼 먼저 수행하므로 `401`, `403` 오류는 기존과 같습니다.
* 인증을 통과한 v2 워터마크 삽입·검출 요청은 live 키와 `sk_test_` 키 모두 `410 Gone`과 `{ "code": "WATERMARK_V2_API_SUNSET" }`을 받습니다. `Link` 헤더가 v3 엔드포인트를 가리키니 v3로 새로 요청하세요.
* 그때까지 처리를 시작하지 않은 v2 주문은 처리되지 않고 실패로 확정됩니다. 주문 상세(`errors.errorCode`)와 실패 웹훅에 `WATERMARK_V2_API_SUNSET`과 v3 안내 메시지가 담기며, 과금되지 않고 크레딧은 복원됩니다.
* 종료 시각에 이미 처리 중인 주문은 결과 기록까지 마칩니다. 이후 새로 다시 실행하지는 않습니다.
* 기존 v2 주문 조회와 이미 생성된 결과·보고서 다운로드는 기존 보존 기간 안에서 계속 이용할 수 있습니다. v2 보고서를 새로 생성하거나 재생성할 수는 없습니다.

그 전까지 요청·응답 동작은 같고, v2 워터마크 엔드포인트의 모든 응답에 `Deprecation: true`, `Sunset: Fri, 09 Oct 2026 00:00:00 GMT`, `Link` 헤더만 추가됩니다.

## 다음 단계

> **[워터마크 삽입](https://docs.contentcare.ai/ko/quickstart/watermark-embed.md)**
>
> 이미지와 PDF에 비가시성 워터마크를 삽입합니다.

> **[테스트 API 키](https://docs.contentcare.ai/ko/test-api-keys.md)**
>
> 크레딧 사용 없이 v3 흐름을 검증합니다.

> **[웹훅](https://docs.contentcare.ai/ko/webhooks.md)**
>
> 검출이 끝나면 알림을 받습니다.

> **[API 레퍼런스](https://docs.contentcare.ai/ko/api-reference/watermark-extract/create-order-v3.md)**
>
> v3 워터마크 검출 엔드포인트의 모든 필드를 확인합니다.


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