> ## 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 で画像と PDF に不可視 watermark を埋め込む

このガイドでは、v3 API で不可視 watermark を埋め込む手順を、注文の作成から watermark が埋め込まれたファイルのダウンロードまで説明します。

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

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

## 事前準備

* BIZ MORI API キー（[こちらから発行](https://app.contentcare.ai/keys)）
* watermark を埋め込む画像または PDF ファイル（`jpg`、`jpeg`、`png`、`webp`、`bmp`、`tif`、`tiff`、`pdf`）

> [!NOTE]
>
> 自動発行される [`sk_test_` キー](https://docs.contentcare.ai/ja/test-api-keys)を使うと、実際の watermark 処理を行わず、クレジットも消費せずに v3 のフロー全体を検証できます。テスト注文はアップロードの約 5 秒後に完了し、ダウンロード URL は実際の結果ではなく固定のサンプルファイルを返します。watermark が埋め込まれた実際のファイルが必要な場合は、本番用の API キーを使用してください。

## 処理の流れ

1. ファイル名とファイルごとの watermark 文言を指定して**注文を作成**します。
2. レスポンスのプリサインド `uploadUrl` に**各ファイルを `PUT` でアップロード**します。すべてのファイルがアップロードされると処理が自動的に始まり、confirm ステップはありません。
3. `GET /api/v3/orders/{orderId}` をポーリングするか [Webhook](https://docs.contentcare.ai/ja/webhooks.md) を受信して、**注文の完了を待ちます**。
4. **結果をダウンロード**します。1 回の呼び出しで、結果ファイルごとの URL と結果全体の URL をまとめて取得できます。

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

watermark 埋め込み注文を作成します。各ファイルには watermark 文言を 1〜10 個指定でき、**文言 1 つごとに結果ファイルが 1 つ作成され、1 クレジットを消費します**。次のリクエストは 1 枚の画像に 2 つの文言を埋め込むため、結果ファイルが 2 つ作成され、2 クレジットを消費します。

```bash cURL theme={null}
ORDER_IDEMPOTENCY_KEY=$(uuidgen | tr '[:upper:]' '[:lower:]')
curl -X POST https://api.bizmori.com/api/v3/orders/wtr-embed \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "'$ORDER_IDEMPOTENCY_KEY'",
    "files": [
      {
        "fileName": "photo.jpg",
        "watermarks": [{ "text": "PARTNER-A" }, { "text": "PARTNER-B" }]
      }
    ]
  }'
```

```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-embed`, {
  method: 'POST',
  headers: { ...headers, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    idempotencyKey: randomUUID(),
    files: [
      {
        fileName: 'photo.jpg',
        watermarks: [{ text: 'PARTNER-A' }, { text: 'PARTNER-B' }],
      },
    ],
  }),
});
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;
}

// `file` はユーザーが選択した File、`watermarkTexts` は string[] です
const order = await api('/orders/wtr-embed', {
  method: 'POST',
  idempotencyKey: crypto.randomUUID(),
  body: {
    files: [
      {
        fileName: file.name,
        watermarks: watermarkTexts.map((text) => ({ text })),
      },
    ],
  },
});
```

```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-embed',
    headers=HEADERS,
    json={
        'idempotencyKey': str(uuid.uuid4()),
        'files': [
            {
                'fileName': 'photo.jpg',
                'watermarks': [{'text': 'PARTNER-A'}, {'text': 'PARTNER-B'}],
            }
        ],
    },
)
order = res.json()['data']
```

**レスポンス:**

```json theme={null}
{
  "data": {
    "orderId": "1234567890",
    "status": "pending",
    "processingDelayed": false,
    "expiresAt": "2026-10-10T03:00:00.000Z",
    "files": [
      {
        "fileId": "501",
        "uploadUrl": "https://s3.ap-northeast-2.amazonaws.com/..."
      }
    ],
    "outputs": [
      {
        "fileId": "501-1",
        "inputFileId": "501",
        "outputIndex": 0,
        "watermarkText": "PARTNER-A",
        "status": "pending",
        "mediaKind": "image"
      },
      {
        "fileId": "501-2",
        "inputFileId": "501",
        "outputIndex": 1,
        "watermarkText": "PARTNER-B",
        "status": "pending",
        "mediaKind": "image"
      }
    ],
    "result": null,
    "reports": []
  }
}
```

* `files[]` はリクエストの `files` と同じ順序で返されます。リクエストの `files[i]` のファイルは `files[i].uploadUrl` にアップロードしてください。
* `outputs[]` には watermark 文言ごとに 1 件ずつ項目があります。ここの `fileId`（`501-1` など）はこの作成レスポンスでだけ使われます。注文詳細とダウンロードのレスポンスでは、結果ファイルを数字の `fileId` で区別します。
* `expiresAt` は注文ファイルが削除される日時です。それまでに結果をダウンロードしてください。

> [!TIP]
>
> 1 つの注文に最大 100 ファイルを含めることができ、画像と PDF を混在させることもできます。すでに公開されているファイルは、アップロードする代わりに `originalFileUrl` に HTTPS URL を指定できます。BIZ MORI がファイルを直接取得し（最大 32 MiB、リダイレクトは追跡しません）、そのファイルの `uploadUrl` は `null` になります。

> [!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}
curl -X PUT "https://s3.ap-northeast-2.amazonaws.com/..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

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

const upload = await fetch(order.files[0].uploadUrl, {
  method: 'PUT',
  headers: { 'Content-Type': 'image/jpeg' },
  body: await readFile('photo.jpg'),
});
if (!upload.ok) throw new Error(`UPLOAD_FAILED_${upload.status}`);
```

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

await putFile(file, order.files[0].uploadUrl, (ratio) => setProgress(ratio));
```

```python Python theme={null}
with open('photo.jpg', 'rb') as f:
    upload = requests.put(
        order['files'][0]['uploadUrl'],
        headers={'Content-Type': 'image/jpeg'},
        data=f,
    )
upload.raise_for_status()
```

> [!NOTE]
>
> **confirm ステップは必要ありません。** 注文のすべてのファイルがアップロードされると、処理が自動的に始まります。

> [!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` になるまで注文をポーリングしてください。ポーリングの代わりに [Webhook](https://docs.contentcare.ai/ja/webhooks.md) を設定すると、`order.watermarkEmbed.completed` または `order.watermarkEmbed.failed` を受信できます。

```bash cURL theme={null}
curl https://api.bizmori.com/api/v3/orders/1234567890 \
  -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);
// finished.result.outputFiles には watermark 文言ごとに 1 件ずつ項目があります
```

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

// 待ち時間が長くなるほどポーリング間隔を広げます。すぐ終わる処理は素早く応答し、
// 時間のかかる処理で API を叩き続けないようにします。無限にポーリングせず、10 分で諦めます。
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'])
```

| `status` | 意味 |
| - | - |
| `pending` | ファイルのアップロード待ち |
| `inProgress` | 処理中 |
| `complete` | ダウンロード可能 |
| `failed` | 処理失敗。消費したクレジットは戻ります |
| `expired` | 保管期間が終了し、ファイルが削除された |

注文が `complete` になると、`result.outputFiles[]` に watermark 文言ごとに結果ファイルが 1 つずつ入ります。注文詳細はすべての注文の種類で同じ形式です。すべてのフィールドは[注文詳細を取得](https://docs.contentcare.ai/ja/api-reference/orders/get-order-v3.md)を参照してください。

```json theme={null}
{
  "data": {
    "type": "watermarkEmbed",
    "orderId": "1234567890",
    "orderName": "photo.jpg",
    "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": "501", "fileName": "photo.jpg" }
    ],
    "result": {
      "outputFiles": [
        { "fileId": "8812", "fileName": "photo.jpg", "inputFileId": "501", "watermarkText": "PARTNER-A" },
        { "fileId": "8813", "fileName": "photo.jpg", "inputFileId": "501", "watermarkText": "PARTNER-B" }
      ]
    }
  }
}
```

`processingDelayed` が `true` の場合、自動再試行をすべて使い切り、手動での復旧を待っている状態です。注文は `inProgress` のままなので、ポーリングを続けるか Webhook を待ってください。

`order.watermarkEmbed.failed` Webhook には、失敗の原因を示す `errorCode` が含まれます。イベントのペイロードとエラーコードは [Webhook](https://docs.contentcare.ai/ja/webhooks.md) を参照してください。

## ステップ4: 結果のダウンロード

`status` が `complete` になったら、ダウンロード URL をリクエストします。1 回の呼び出しで、結果ファイルごとの URL（`files[]`）と結果全体を受け取る URL（`archiveUrl`）をまとめて取得できます。結果ファイルが 1 つなら `archiveUrl` はそのファイルを、複数なら ZIP ファイルを指します。

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

```javascript Node.js theme={null}
const res = await fetch(`${API_BASE}/orders/${order.orderId}/download`, { headers });
const { data } = await res.json();
// data.archiveUrl と data.files[].downloadUrl は data.expiresInSeconds 秒間（最大 3600 秒）有効です
```

```jsx React theme={null}
// URL は 1 時間以内に期限切れになるため、ユーザーがダウンロードを押した時点でリクエストします。
const { archiveUrl } = await api(`/orders/${order.orderId}/download`);
window.location.assign(archiveUrl);
```

```python Python theme={null}
res = requests.get(f'{API_BASE}/orders/{order["orderId"]}/download', headers=HEADERS)
archive_url = res.json()['data']['archiveUrl']
```

**レスポンス:**

```json theme={null}
{
  "data": {
    "archiveUrl": "https://s3.ap-northeast-2.amazonaws.com/...",
    "files": [
      { "fileId": "8812", "downloadUrl": "https://s3.ap-northeast-2.amazonaws.com/..." },
      { "fileId": "8813", "downloadUrl": "https://s3.ap-northeast-2.amazonaws.com/..." }
    ],
    "expiresInSeconds": 3600
  }
}
```

* `files[]` は `result.outputFiles[]` と同じ結果ファイルを同じ順序で含み、`fileId` も同じです。`fileId` で突き合わせると、各ファイルにどの watermark 文言が入っているかがわかります。
* URL は最大 **3600 秒**有効で、保管期限が先に来る場合はそれより短くなります。保存せずに、ダウンロードのたびに新しくリクエストしてください。`expiresAt` を過ぎると、このエンドポイントは `400 ORDER_EXPIRED` を返します。

> [!NOTE]
>
> テスト注文では、`archiveUrl` と `files[]` は固定のサンプルファイルを指します（結果が複数ある場合は ZIP）。実際に watermark が埋め込まれたファイルではありません。

## React 完全なサンプル

上記すべてを 1 つのコンポーネントにまとめました：進捗を表示する単一ファイルのアップロード、追加・削除と重複チェックが可能な watermark 文言のリスト、バックオフ付きポーリング、押すたびに新しい URL を取得するダウンロードボタンまでを含みます。React 以外の依存関係はありません。

```jsx WatermarkEmbedUploader.jsx theme={null}
import { useMemo, 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 MAX_WATERMARKS = 10;
const MAX_TEXT_LENGTH = 1000;

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 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');
}

export default function WatermarkEmbedUploader() {
  const [file, setFile] = useState(null);
  // 遅延初期化：こうしないと、レンダリングのたびに crypto.randomUUID() が実行されます。
  const [watermarks, setWatermarks] = useState(() => [{ id: crypto.randomUUID(), text: '' }]);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState('idle');
  const [error, setError] = useState(null);
  const [orderId, setOrderId] = useState(null);
  const inFlight = useRef(false);

  const busy = status !== 'idle' && status !== 'complete';

  // 最初に出現した文言以降の同じ文言をすべて重複としてマークします。空の行は
  // 下の「空の文言は不可」ルールですでに弾かれるため除外します。
  const duplicateIds = useMemo(() => {
    const seen = new Map();
    const duplicates = new Set();
    watermarks.forEach((watermark) => {
      const text = watermark.text.trim();
      if (text === '') return;
      if (seen.has(text)) duplicates.add(watermark.id);
      else seen.set(text, watermark.id);
    });
    return duplicates;
  }, [watermarks]);

  const canSubmit =
    file !== null &&
    contentTypeOf(file.name) !== undefined &&
    watermarks.length > 0 &&
    watermarks.every((watermark) => watermark.text.trim() !== '') &&
    duplicateIds.size === 0 &&
    !busy;

  function updateWatermark(id, text) {
    setWatermarks((prev) => prev.map((watermark) => (watermark.id === id ? { ...watermark, text } : watermark)));
  }

  function addWatermark() {
    setWatermarks((prev) => [...prev, { id: crypto.randomUUID(), text: '' }]);
  }

  function removeWatermark(id) {
    setWatermarks((prev) => prev.filter((watermark) => watermark.id !== id));
  }

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

    // 選択内容を固定します。リクエスト中にユーザーがフォームを編集しても、
    // アップロードには送信時点の値を使う必要があります。
    const selectedFile = file;
    const selectedWatermarks = watermarks;
    setError(null);
    setOrderId(null);
    setProgress(0);

    try {
      setStatus('creating order');
      const order = await api('/orders/wtr-embed', {
        method: 'POST',
        idempotencyKey: crypto.randomUUID(),
        body: {
          files: [
            {
              fileName: selectedFile.name,
              watermarks: selectedWatermarks.map((watermark) => ({ text: watermark.text.trim() })),
            },
          ],
        },
      });

      setOrderId(order.orderId);
      setStatus('uploading');
      await putFile(selectedFile, order.files[0].uploadUrl, setProgress);

      // confirm ステップなし — アップロードが完了すると処理が自動的に始まります。
      const finished = await pollOrder(order.orderId, { onStatus: setStatus });
      if (finished.status !== 'complete') throw new Error(`ORDER_${finished.status.toUpperCase()}`);

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

  // ダウンロード URL は 1 時間以内に期限切れになるため、押すたびに新しく取得します。
  async function download() {
    try {
      const { archiveUrl } = await api(`/orders/${orderId}/download`);
      window.location.assign(archiveUrl);
    } catch (caught) {
      setError(caught.message);
    }
  }

  return (
    <form onSubmit={embedWatermarks}>
      <input
        type="file"
        accept={ACCEPT_FORMATS}
        disabled={busy}
        onChange={(event) => {
          setFile(event.target.files[0] ?? null);
          setError(null);
          setProgress(0);
          setStatus('idle');
        }}
      />

      <ul>
        {watermarks.map((watermark, index) => (
          <li key={watermark.id}>
            <input
              type="text"
              value={watermark.text}
              maxLength={MAX_TEXT_LENGTH}
              disabled={busy}
              placeholder={`Watermark text #${index + 1}`}
              onChange={(event) => updateWatermark(watermark.id, event.target.value)}
              style={{ borderColor: duplicateIds.has(watermark.id) ? 'red' : undefined }}
            />
            <button type="button" disabled={busy || watermarks.length === 1} onClick={() => removeWatermark(watermark.id)}>
              Remove
            </button>
          </li>
        ))}
      </ul>
      <button type="button" disabled={busy || watermarks.length >= MAX_WATERMARKS} onClick={addWatermark}>
        Add watermark text ({watermarks.length}/{MAX_WATERMARKS})
      </button>

      <p>This order uses {watermarks.length} credit{watermarks.length === 1 ? '' : 's'}.</p>
      <button type="submit" disabled={!canSubmit}>
        Embed watermark{watermarks.length === 1 ? '' : 's'}
      </button>

      {status !== 'idle' && <p>Status: {status}</p>}
      {status === 'uploading' && <p>Upload progress: {Math.round(progress * 100)}%</p>}

      {status === 'complete' && (
        <button type="button" onClick={download}>
          Download {watermarks.length === 1 ? 'watermarked file' : 'ZIP'}
        </button>
      )}
      {error && <p role="alert">Failed: {error}{orderId && ` (orderId: ${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 アドレスでは動作しません。

## エラー処理

| HTTP ステータスコード | 意味 | 対応 |
| - | - | - |
| `400` | 不正なリクエスト、処理が終わっていない注文をダウンロードしたときの `ORDER_NOT_COMPLETED`、または保管期間を過ぎた `ORDER_EXPIRED` | パラメータとファイルの拡張子を確認してください。`complete` になってからダウンロードしてください。期限切れの注文はダウンロードできません |
| `401` | 認証失敗 | API キーを確認 |
| `409` | `WATERMARK_V2_CONFLICT`：使用済みの `idempotencyKey` で本文の異なるリクエストを送信 | 新しい注文には新しい `idempotencyKey` を使用してください |
| `429` | レート制限、またはプラン使用量の超過時は `PLAN_LIMIT_EXCEEDED` | `code` を確認してください。レート制限は再試行で解消しますが、`PLAN_LIMIT_EXCEEDED` は解消しないためプランのアップグレードが必要です |
| `503` | `WATERMARK_V2_UNAVAILABLE`：watermark 処理が一時的に利用できない | 同じ `idempotencyKey` でしばらくしてから再試行してください |

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

## v2 から v3 への移行

v3 のリクエスト本文は v2 と同じ形式です。ほとんどの場合、エンドポイントのパスと結果の読み取り方を変えるだけで移行できます。

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

| | v2 | v3 |
| - | - | - |
| 注文の作成 | `POST /api/v2/orders/wtr-embed` | `POST /api/v3/orders/wtr-embed` |
| リクエスト本文 | `files[].fileName`、`originalFileUrl`、`watermarks` | 同じ |
| クレジット | — | 画像・PDF を問わず watermark 文言ごとに 1 クレジット |
| 結果 | 注文全体をまとめてダウンロード | watermark 文言ごとに結果ファイルが 1 つずつ `result.outputFiles[]` に作成される |
| 注文の取得 | `GET /api/v2/orders/{orderId}`（非推奨） | [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/ja/api-reference/orders/get-order-v3) |
| ダウンロード | `GET /api/v2/orders/{orderId}/download`（非推奨、URL は 7 日間有効） | [`GET /api/v3/orders/{orderId}/download`](https://docs.contentcare.ai/ja/api-reference/orders/download-order-v3): `archiveUrl` と結果ファイルごとの URL（最大 1 時間有効） |
| アップロード URL | 1 時間有効、`refresh-urls` で更新 | 900 秒間有効、`GET /api/v3/orders/{orderId}` を再度呼び出して新しい URL を取得 |
| confirm ステップ | なし | なし |
| Webhook | `order.watermarkEmbed.completed` / `failed` | イベント名と `data` フィールドは同じ |

変わらない点：1 つの注文に画像と PDF を混在でき、`originalFileUrl` を指定すればアップロードを省略でき、confirm を呼び出さずに処理が始まります。

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

**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-extract.md)**
>
> ファイルから 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-embed/create-order-v3.md)**
>
> v3 watermark 埋め込みエンドポイントのすべてのフィールドを確認します。


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