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

# 不可視 watermark の抽出

> v3 API でファイルから不可視 watermark を検出する

このガイドでは、v3 API でファイルに不可視 watermark が含まれているかを確認する手順を、注文の作成から結果の確認、PDF レポートのダウンロードまで説明します。

すべてのエンドポイントは純粋な HTTPS と JSON を使用するため、サーバーからでもブラウザからでも同じように呼び出せます。各ステップには **React** タブがあり、そのままコピーして使える[完全なコンポーネント](#react-完全なサンプル)はページ下部にあります。

> [!WARNING]
>
> v2 の watermark 抽出エンドポイント（`POST /api/v2/orders/wtr-extract`）は 2026 年 10 月 9 日 09:00 KST（00:00 UTC）にサポートを終了しました。このガイドは v3 API を前提に説明します。すでに v2 を使用している場合は、[v2 から v3 への移行](#v2-から-v3-への移行)を参照してください。

> [!WARNING]
>
> **v3 の watermark 抽出では、v2 で埋め込んだ watermark を検出できません。** エンドポイントを v3 に切り替えるだけでは既存の v2 watermark 画像は検出されず、v2 のサポート終了後に v2 の検出を例外的に受け付けることもありません。

## 事前準備

* BIZ MORI API キー（[こちらから発行](https://app.contentcare.ai/keys)）
* watermark を埋め込んだ**元のファイル** — watermark が埋め込まれた結果ファイルではなく、[watermark の埋め込み](https://docs.contentcare.ai/ja/quickstart/watermark-embed.md)で送信したファイルです
* **検査するファイル**:
  * 元のファイルが画像（`jpg`、`jpeg`、`png`、`webp`、`bmp`、`tif`、`tiff`）の場合、検査するファイルも画像です。
  * 元のファイルが PDF の場合、検査するファイルは PDF、またはその PDF をキャプチャした PNG・JPEG 画像です。

> [!NOTE]
>
> 自動発行される [`sk_test_` キー](https://docs.contentcare.ai/ja/test-api-keys)を使うと、実際の watermark 検出を行わず、クレジットも消費せずに v3 のフロー全体を検証できます。テストアップロードはファイルの内容を破棄し、結果は検査するファイルの名前で決まります。`_detected` を含めば `detected`、それ以外は `undetected` になり、どちらかのファイル名に `_fail` が含まれると注文は失敗します。

## 処理の流れ

1. 元のファイルと検査するファイルの名前を指定して**注文を作成**します。
2. レスポンスのプリサインド URL に**2 つのファイルを `PUT` でアップロード**します。両方のアップロードが終わると検出が自動的に始まり、confirm ステップはありません。
3. `GET /api/v3/orders/{orderId}` をポーリングするか [Webhook](https://docs.contentcare.ai/ja/webhooks.md) を受信して、**結果を確認**します。
4. 必要に応じて、結果の **PDF レポートを作成**します。

## ステップ1: 注文の作成

watermark 抽出注文を作成します。1 つの注文で 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` はユーザーが選択した 2 つの 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` には常に 2 つの項目があります。**`files[0]` は元のファイル**、**`files[1]` は検査するファイル**です。注文詳細では、同じファイルが `inputFiles[]` に `role` が `original`・`query` の項目として表示されます。

> [!NOTE]
>
> 同じ `idempotencyKey` は、同じリクエストを再試行するときだけ再利用してください。その場合は新しい注文を作成せず、既存の注文を返します。同じキーでリクエスト本文が異なる場合は `409 WATERMARK_V2_CONFLICT` を返します。

## ステップ2: 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 ステップは必要ありません。** 2 つのファイルのアップロードが終わると、検出が自動的に始まります。

> [!NOTE]
>
> 本番キーの `uploadUrl` は **900 秒**で期限切れになります。期限が切れた場合は [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/ja/api-reference/orders/get-order-v3) を呼び出すと、まだアップロードされていないファイルの新しい `inputFiles[].uploadUrl` を取得できます。注文の作成から **12 時間**以内に両方のファイルをアップロードしないと、注文は `failed` になります。テストキーでは 1 時間有効な `https://api.bizmori.com/api/v2/test-uploads/{signedToken}` が返されます。認証なしで同じように PUT でき、アップロードしたファイルの内容は破棄されます。

## ステップ3: 結果の確認

`status` が `complete` になるまで注文をポーリングし、`result` を読み取ってください。ポーリングの代わりに [Webhook](https://docs.contentcare.ai/ja/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'])
```

**レスポンス（watermark が検出された場合）:**

```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` | watermark が見つかった |
| `undetected` | ファイル全体を検査したが、watermark はなかった |
| `inconclusive` | 候補はあるが、判断できるほどの確度がない |
| `unsupported` | 処理できない入力。クレジットは戻ります |
| `error` | 検出処理に失敗。「watermark なし」とは異なります。クレジットは戻ります |

* `result.watermarkText` は埋め込んだ文言です。結果が `detected` で、自分のアカウントが埋め込み、埋め込み記録がちょうど 1 件のときだけ返されます。それ以外は `null` です。
* `result.reportSupported` は、この結果で PDF レポートを作成できるかを示します。PDF をキャプチャした画像を検査した場合は `false` です。
* `result.reports[]` には、この注文で依頼したレポートごとのステータスが入ります。
* `processingDelayed` が `true` の場合、自動再試行をすべて使い切り、手動での復旧を待っている状態です。注文は `inProgress` のままなので、ポーリングを続けるか Webhook を待ってください。

> [!NOTE]
>
> Webhook のペイロードは v2 のフィールド名をそのまま使います。`order.watermarkExtract.completed` は結果を `result.detectionStatus` と同じ値の `statusCode` で通知し、`watermarkFound` と `watermarkInfo.text` も含まれます。ペイロード全体は [Webhook](https://docs.contentcare.ai/ja/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": "ja-JP" }'

curl https://api.bizmori.com/api/v3/orders/1234567891/reports/ja-JP \
  -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, 'ja-JP');
```

```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'], 'ja-JP')
```

**レスポンス（作成完了）:**

```json theme={null}
{
  "data": {
    "orderId": "1234567891",
    "reportType": "watermarkDetection",
    "locale": "ja-JP",
    "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 完全なサンプル

上記すべてを 1 つのコンポーネントにまとめました：2 つのファイル選択、進捗を表示するアップロード、バックオフ付きポーリング、`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 = 'ja-JP';

const RESULT_LABELS = {
  detected: 'watermark が検出されました',
  undetected: 'このファイルから watermark は見つかりませんでした。',
  inconclusive: 'watermark らしき信号はありますが、判断できません。',
  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 をそのまま再利用するため、再試行した作成リクエストが
  // 2 つ目の注文を作ることはありません。認証失敗は `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 には 2 つのケースがあります：一時的なレート制限と、プランの利用量を使い切り
      // 再試行しても解消しない 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}>
        watermark を確認
      </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` のみが含まれ、watermark 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` | watermark 処理またはレポート作成が一時的に利用できない | しばらくしてから再試行してください |

エラーコードの一覧は、[エラーコード](https://docs.contentcare.ai/ja/errors.md) ページを参照してください。

## v2 から v3 への移行

v3 では検査するファイルを常に元のファイルと比較するため、リクエストで両方のファイルを指定します。

> [!WARNING]
>
> **v3 の watermark 抽出では、v2 で埋め込んだ watermark を検出できません。** エンドポイントを v3 に切り替えるだけでは既存の v2 watermark 画像は検出されず、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}`（非推奨）→ `statusCode`（`detected` / `undetected`） | [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/ja/api-reference/orders/get-order-v3) → `result.detectionStatus`（5 種類）、`result.watermarkText`、`result.reportSupported` |
| クレジット | — | 画像・PDF を問わず注文ごとに 1 クレジット。`unsupported`・`error` の結果は返還 |
| PDF レポート | なし | レポートの[作成](https://docs.contentcare.ai/ja/api-reference/reports/create-report.md)・[取得](https://docs.contentcare.ai/ja/api-reference/reports/get-report.md) |
| confirm ステップ | なし | なし |
| Webhook | `order.watermarkExtract.completed` / `failed` | イベント名と `data` フィールドは同じ。`statusCode` に `inconclusive`、`unsupported`、`error` が追加 |

> [!WARNING]
>
> 注文の取得には v3 のエンドポイントを使用してください。`GET /api/v3/orders/{orderId}` は、v2・v3 のどちらで作成した注文にも同じ形式で動作します。非推奨の `GET /api/v2/orders/{orderId}` は v3 注文の共通フィールドだけを返し、`result` は含みません。

**v2 のサポート終了後の動作**（2026 年 10 月 9 日 09:00 KST、00:00 UTC から）:

* 認証・権限の確認はこれまでどおり先に行われるため、`401`・`403` エラーは従来と同じです。
* 認証を通過した v2 の watermark 埋め込み・抽出リクエストには、本番キーと `sk_test_` キーのどちらでも `410 Gone` と `{ "code": "WATERMARK_V2_API_SUNSET" }` が返されます。`Link` ヘッダーが v3 エンドポイントを示すので、v3 で新しくリクエストしてください。
* その時点までに処理が始まっていない v2 注文は、処理されずに失敗として確定します。注文の詳細（`errors.errorCode`）と失敗 Webhook には `WATERMARK_V2_API_SUNSET` と v3 への案内メッセージが含まれ、課金されずクレジットは戻ります。
* 終了時点ですでに処理中の注文は、結果の記録まで完了します。その後に改めて実行されることはありません。
* 既存の v2 注文の取得と、作成済みの結果・レポートのダウンロードは、既存の保管期間内であれば引き続き利用できます。v2 レポートの新規作成・再作成はできません。

それまではリクエストとレスポンスの動作は変わらず、これらのエンドポイントのすべてのレスポンスに `Deprecation: true`、`Sunset: Fri, 09 Oct 2026 00:00:00 GMT`、`Link` ヘッダーが付くだけです。

## 次のステップ

> **[不可視 watermark の埋め込み](https://docs.contentcare.ai/ja/quickstart/watermark-embed.md)**
>
> 画像と PDF に不可視 watermark を埋め込みます。

> **[テスト API キー](https://docs.contentcare.ai/ja/test-api-keys.md)**
>
> クレジットを消費せずに v3 のフローを検証します。

> **[Webhook](https://docs.contentcare.ai/ja/webhooks.md)**
>
> 検出が完了したら通知を受け取ります。

> **[API リファレンス](https://docs.contentcare.ai/ja/api-reference/watermark-extract/create-order-v3.md)**
>
> v3 watermark 抽出エンドポイントのすべてのフィールドを確認します。


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