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

> Check a file for an invisible watermark with the v3 API

This guide walks you through checking a file for an invisible watermark with the v3 API — from creating an order to reading the result and downloading a PDF report.

Every endpoint here is plain HTTPS and JSON, so the same calls work from a server or straight from the browser. Each step includes a **React** tab, and a [full copy-paste component](#full-react-example) is at the bottom of the page.

> [!WARNING]
>
> The v2 watermark extract endpoint (`POST /api/v2/orders/wtr-extract`) ended support at 09:00 KST on October 9, 2026 (00:00 UTC). This guide uses the v3 API. If you already call v2, see [Migrating from v2 to v3](#migrating-from-v2-to-v3).

> [!WARNING]
>
> **v3 watermark extract cannot detect watermarks embedded with v2.** Switching to the v3 endpoint does not make existing v2-watermarked images detectable, and there will be no exception for v2 detection after v2 support ends.

## Prerequisites

* A BIZ MORI API key ([get one here](https://app.contentcare.ai/keys))
* The **original file** the watermark was embedded into — the file you sent to [watermark embed](https://docs.contentcare.ai/quickstart/watermark-embed.md), not the watermarked output
* The **file to check**:
  * If the original is an image (`jpg`, `jpeg`, `png`, `webp`, `bmp`, `tif`, or `tiff`), the file to check is an image.
  * If the original is a PDF, the file to check is a PDF, or a PNG or JPEG image captured from it.

> [!NOTE]
>
> Use the automatically issued [`sk_test_` key](https://docs.contentcare.ai/test-api-keys) to exercise the whole v3 flow without running watermark extraction or consuming credits. Test uploads discard file contents, and the name of the file to check selects the result: `_detected` returns `detected`, anything else returns `undetected`, and `_fail` on either file fails the order.

## How it works

1. **Create an order** with the names of the original file and the file to check.
2. **Upload both files** with `PUT` to the presigned URLs in the response. Extraction starts automatically once both are uploaded — there is no confirm step.
3. **Read the result** by polling `GET /api/v3/orders/{orderId}` or by receiving a [webhook](https://docs.contentcare.ai/webhooks.md).
4. **Create a PDF report** for the result if you need one.

## Step 1: Create an order

Create a watermark extract order. Each order checks one file and uses 1 credit.

```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}
// One helper for every JSON call. The API returns `{ code: "ERROR_CODE" }`
// on failure, so surface that code instead of a bare status number.
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` and `checkedFile` are the two Files the user picked
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']
```

**Response:**

```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` always has two entries: **`files[0]` is the original file** and **`files[1]` is the file to check**. In the order details, the same files appear in `inputFiles[]` with `role` set to `original` and `query`.

> [!NOTE]
>
> Reuse the same `idempotencyKey` only when you retry the same request. It returns the existing order instead of creating a new one. The same key with a different request body returns `409 WATERMARK_V2_CONFLICT`.

## Step 2: Upload both files

PUT each file to its presigned `uploadUrl`. This is a direct upload to storage — **no Authorization header needed**.

Send a `Content-Type` header that matches the file extension. The presigned URL is signed for that type, so any other value is rejected.

| Extension | `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]: the original file
curl -X PUT "https://s3.ap-northeast-2.amazonaws.com/...original..." \
  -H "Content-Type: image/png" \
  --data-binary @campaign.png

# files[1]: the file to check
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'); // original
await putFile(order.files[1].uploadUrl, 'suspect.png', 'image/png'); // file to check
```

```jsx React theme={null}
// The presigned URL is signed for the type that matches the file extension,
// so derive Content-Type from the name instead of trusting `file.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() cannot report upload progress, so use XMLHttpRequest for the PUT.
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] is the original, files[1] is the file to check.
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')  # original
put_file(order['files'][1]['uploadUrl'], 'suspect.png', 'image/png')   # file to check
```

> [!NOTE]
>
> **No confirm step needed.** Extraction starts automatically once both files are uploaded.

> [!NOTE]
>
> A live `uploadUrl` expires after **900 seconds**. If it expires, call [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/api-reference/orders/get-order-v3) to receive a fresh `inputFiles[].uploadUrl` for each file that is still waiting for upload. Upload both files within **12 hours** of creating the order, or the order becomes `failed`. Test keys receive `https://api.bizmori.com/api/v2/test-uploads/{signedToken}` instead, valid for 1 hour; it accepts the same unauthenticated PUT and discards the file contents.

## Step 3: Read the result

Poll the order until `status` is `complete`, then read `result`. You can also set up [webhooks](https://docs.contentcare.ai/webhooks.md) to receive `order.watermarkExtract.completed` or `order.watermarkExtract.failed` instead of polling.

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

// Back off as the wait grows: quick answers stay quick, long jobs stop
// hammering the API. Give up after 10 minutes rather than polling forever.
// Poll on `status` — the detection outcome arrives in `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'])
```

**Response (watermark detected):**

```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": []
    }
  }
}
```

Poll `status` to know when the order is done, then read `result.detectionStatus` for the outcome:

| `status` | Meaning |
| - | - |
| `pending` | Waiting for file uploads |
| `inProgress` | Extraction running |
| `complete` | Finished — read `result` |
| `failed` | Extraction failed. The used credit is restored |
| `expired` | The retention period ended and the files were deleted |

| `result.detectionStatus` | Meaning |
| - | - |
| `detected` | A watermark was found |
| `undetected` | The whole file was checked and no watermark was found |
| `inconclusive` | A candidate was found, but not with enough confidence to decide |
| `unsupported` | The input cannot be processed. The credit is restored |
| `error` | Detection failed. This is not the same as "no watermark". The credit is restored |

* `result.watermarkText` is the embedded text. It is returned only when the result is `detected`, your account embedded the watermark, and exactly one embed record matches. Otherwise it is `null`.
* `result.reportSupported` tells you whether you can create a PDF report for this result. It is `false` when you checked an image captured from a PDF.
* `result.reports[]` lists the status of each report you requested for this order.
* If `processingDelayed` is `true`, automatic retries are exhausted and the order is waiting for manual recovery. It stays `inProgress`, so keep polling or wait for the webhook.

> [!NOTE]
>
> Webhook payloads keep the v2 field names: `order.watermarkExtract.completed` reports the outcome in `statusCode`, which uses the same values as `result.detectionStatus`, plus `watermarkFound` and `watermarkInfo.text`. See [Webhooks](https://docs.contentcare.ai/webhooks.md) for the full payload.

## Step 4: Create a PDF report (optional)

When `result.reportSupported` is `true`, you can create a PDF report of the result in Korean (`ko-KR`), English (`en-US`), or Japanese (`ja-JP`). Reports for PDF files support `ko-KR` only. A report uses 1 credit, charged only when it is generated successfully.

Request the report, then poll it until `status` is `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": "en-US" }'

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

```jsx React theme={null}
// The report URL expires after 300 seconds. Call the GET endpoint again
// each time the user clicks Download, and navigate to the fresh 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'], 'en-US')
```

**Response (completed):**

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

The `download.url` expires after **300 seconds**. Don't store it — call `GET /api/v3/orders/{orderId}/reports/{locale}` again whenever you need to download the report. If a report is `failed` and `failure.retryable` is `true`, call the POST endpoint again to retry.

> [!NOTE]
>
> With a test key, reports are available only for a `detected` test order. They return `completed` immediately with a one-page sample PDF and do not use credits.

## Full React example

Everything above, wired into one component: two file pickers, uploads with progress, backoff polling, the result rendered from `detectionStatus`, and a report button. No dependencies beyond 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 = 'en-US';

const RESULT_LABELS = {
  detected: 'Watermark detected',
  undetected: 'No watermark was found in this file.',
  inconclusive: 'A possible watermark was found, but the result is inconclusive.',
  unsupported: 'This file cannot be checked. The credit was restored.',
  error: 'Detection failed. The credit was restored — try again.',
};

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 } = {}) {
  // Retries reuse the caller's idempotency key, so a retried create can never
  // produce a second order. Auth failures surface as `AUTH_*` codes — retrying
  // those will not help, so they fall through to the throw below.
  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 covers two cases: a transient rate limit, and PLAN_LIMIT_EXCEEDED,
      // which means the plan quota is spent and will never clear on retry.
      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;

    // Snapshot the selection. The user can swap files while requests are in
    // flight, and each file has to stay paired with its upload 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] is the original, files[1] is the file to check.
      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;
    }
  }

  // The report URL expires after 300 seconds, so fetch it on every click.
  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>
        Original file
        <input
          type="file"
          accept={ACCEPT_FORMATS}
          disabled={busy}
          onChange={(event) => setOriginalFile(event.target.files[0] ?? null)}
        />
      </label>
      <label>
        File to check
        <input
          type="file"
          accept={ACCEPT_FORMATS}
          disabled={busy}
          onChange={(event) => setCheckedFile(event.target.files[0] ?? null)}
        />
      </label>
      <button type="submit" disabled={!canSubmit}>
        Check for watermark
      </button>

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

      {/* `status` says the order finished; `result.detectionStatus` says what was found. */}
      {result && (
        <p>
          {RESULT_LABELS[result.detectionStatus]}
          {result.watermarkText && <strong> {result.watermarkText}</strong>}
        </p>
      )}
      {result?.reportSupported && (
        <button type="button" onClick={openReport}>
          Download PDF report
        </button>
      )}
      {error && <p role="alert">Failed: {error}{order && ` (orderId: ${order.orderId})`}</p>}
    </form>
  );
}
```

> [!NOTE]
>
> `import.meta.env.VITE_MORI_API_KEY` is Vite's syntax. Use `process.env.NEXT_PUBLIC_MORI_API_KEY` on Next.js, or whatever your bundler exposes to client code. `crypto.randomUUID()` needs a secure context: HTTPS and `localhost` are fine, a plain-HTTP LAN address is not.

The public result contains only `detectionStatus`, `reportSupported`, and `watermarkText`. It never includes a watermark ID (MID).

## Error handling

| HTTP Status | Meaning | Action |
| - | - | - |
| `400` | Invalid request, `REPORT_LOCALE_UNSUPPORTED`, or `ORDER_EXPIRED` after the retention period | Check parameters, file extensions, and the report locale |
| `401` | Authentication failed | Verify your API key |
| `404` | `ORDER_NOT_FOUND`, or `REPORT_NOT_FOUND` for a locale you haven't requested | Check the order ID, and create the report before getting it |
| `409` | `WATERMARK_V2_CONFLICT` for a reused `idempotencyKey`, or `REPORT_NOT_AVAILABLE` when the result does not support a report | Use a new `idempotencyKey`, or check `result.reportSupported` |
| `429` | Rate limit, or `PLAN_LIMIT_EXCEEDED` when the plan quota is spent | Check the `code` field. A rate limit clears on retry; `PLAN_LIMIT_EXCEEDED` does not — upgrade the plan |
| `503` | Watermark processing or report generation is temporarily unavailable | Retry later |

For the complete error code reference, see [Error Codes](https://docs.contentcare.ai/errors.md).

## Migrating from v2 to v3

v3 always compares the file to check against its original, so the request now names both files.

> [!WARNING]
>
> **v3 watermark extract cannot detect watermarks embedded with v2.** Switching to the v3 endpoint does not make existing v2-watermarked images detectable, and there will be no exception for v2 detection after v2 support ends.

| | v2 | v3 |
| - | - | - |
| Create an order | `POST /api/v2/orders/wtr-extract` | `POST /api/v3/orders/wtr-extract` |
| Original file | Optional (`includeOriginal`), images only | Required for every order, images and PDFs |
| Request body | `file.fileName`, `file.includeOriginal`, `file.originalFile` | `file.fileName` and `file.originalFile.fileName`. There is no `includeOriginal` |
| Uploads | One file, or two with `includeOriginal` | Always two: `files[0]` is the original, `files[1]` is the file to check |
| Get the result | `GET /api/v2/orders/{orderId}` (deprecated) → `statusCode` (`detected` / `undetected`) | [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/api-reference/orders/get-order-v3) → `result.detectionStatus` (five values), `result.watermarkText`, `result.reportSupported` |
| Credits | — | 1 credit per order, for images and PDFs alike. `unsupported` and `error` results are refunded |
| PDF report | Not available | [Create](https://docs.contentcare.ai/api-reference/reports/create-report.md) and [get](https://docs.contentcare.ai/api-reference/reports/get-report.md) a report |
| Confirm step | None | None |
| Webhooks | `order.watermarkExtract.completed` / `failed` | Same event names and `data` fields. `statusCode` can also be `inconclusive`, `unsupported`, or `error` |

> [!WARNING]
>
> Use the v3 endpoint to get orders. `GET /api/v3/orders/{orderId}` works for orders created with v2 or v3, in the same shape. The deprecated `GET /api/v2/orders/{orderId}` returns only the common fields of a v3 order, without `result`.

**After v2 support ends** (from 09:00 KST on October 9, 2026, which is 00:00 UTC):

* Authentication and permission checks still run first, so `401` and `403` errors are returned as before.
* Authenticated requests to the v2 watermark embed and extract endpoints return `410 Gone` with `{ "code": "WATERMARK_V2_API_SUNSET" }`, for both live and `sk_test_` keys. The `Link` header points to the v3 endpoint. Create new orders with v3.
* v2 orders that have not started processing by then fail without being processed. The order details (`errors.errorCode`) and the failed webhook carry `WATERMARK_V2_API_SUNSET` with a message pointing to v3. The order is not charged, and its credits are restored.
* Orders already processing at that time finish and record their result. No new run starts for them afterwards.
* You can still get existing v2 orders and download results and reports that were already created, within the existing retention period. New v2 reports cannot be created or regenerated.

Until then, the request and response behavior is unchanged, except that every response from these endpoints includes the `Deprecation: true`, `Sunset: Fri, 09 Oct 2026 00:00:00 GMT`, and `Link` headers.

## Next steps

> **[Watermark Embed](https://docs.contentcare.ai/quickstart/watermark-embed.md)**
>
> Embed invisible watermarks into images and PDFs.

> **[Test API keys](https://docs.contentcare.ai/test-api-keys.md)**
>
> Exercise the v3 flow without consuming credits.

> **[Webhooks](https://docs.contentcare.ai/webhooks.md)**
>
> Get notified when extraction completes.

> **[API reference](https://docs.contentcare.ai/api-reference/watermark-extract/create-order-v3.md)**
>
> See every field of the v3 watermark extract endpoint.


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