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

> Embed invisible watermarks into images and PDFs with the v3 API

This guide walks you through embedding invisible watermarks with the v3 API — from creating an order to downloading the watermarked files.

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 embed endpoint (`POST /api/v2/orders/wtr-embed`) 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).

## Prerequisites

* A BIZ MORI API key ([get one here](https://app.contentcare.ai/keys))
* An image or PDF file to watermark (`jpg`, `jpeg`, `png`, `webp`, `bmp`, `tif`, `tiff`, or `pdf`)

> [!NOTE]
>
> Use the automatically issued [`sk_test_` key](https://docs.contentcare.ai/test-api-keys) to exercise the whole v3 flow without running watermark processing or consuming credits. Test orders complete about five seconds after the upload, and their download URLs return a fixed sample file instead of your watermarked output. Use a live API key when you need an actual watermarked file.

## How it works

1. **Create an order** with the file names and the watermark texts for each file.
2. **Upload each file** with `PUT` to the presigned `uploadUrl` in the response. Processing starts automatically once every file is uploaded — there is no confirm step.
3. **Wait for the order to finish** by polling `GET /api/v3/orders/{orderId}` or by receiving a [webhook](https://docs.contentcare.ai/webhooks.md).
4. **Download the results**: one call returns a URL for each output file and one URL for all of them.

## Step 1: Create an order

Create a watermark embed order. Each file takes 1 to 10 watermark texts, and **each text produces its own output file and uses 1 credit**. The request below embeds two texts into one image, so it creates two output files and uses 2 credits.

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

// `file` is the File the user picked, `watermarkTexts` is a 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']
```

**Response:**

```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[]` follows the order of `files` in your request. Use `files[i].uploadUrl` to upload the file you sent as `files[i]`.
* `outputs[]` has one entry per watermark text. Its `fileId` values (such as `501-1`) appear only in this create response. The order details and the download response identify output files with numeric `fileId` values instead.
* `expiresAt` is when the order files are deleted. Download your results before then.

> [!TIP]
>
> One order can hold up to 100 files, and images and PDFs can be mixed. If a file is already hosted, send its public HTTPS URL in `originalFileUrl` instead of uploading it. BIZ MORI downloads the file (up to 32 MiB, redirects are not followed), and that file's `uploadUrl` is `null`.

> [!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 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}
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}
// 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);
  });
}

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]
>
> **No confirm step needed.** Processing starts automatically once every file in the order is 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 every file 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: Wait for the order to finish

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

```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 has one entry per watermark text
```

```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.
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` | Meaning |
| - | - |
| `pending` | Waiting for file uploads |
| `inProgress` | Processing |
| `complete` | Ready for download |
| `failed` | Processing failed. Used credits are restored |
| `expired` | The retention period ended and the files were deleted |

When the order is `complete`, `result.outputFiles[]` lists one output file per watermark text. The order details have the same shape for every order type; see [Get order details](https://docs.contentcare.ai/api-reference/orders/get-order-v3.md) for every field.

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

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.

The `order.watermarkEmbed.failed` webhook includes an `errorCode` that explains why the order failed. See [Webhooks](https://docs.contentcare.ai/webhooks.md) for the event payloads and error codes.

## Step 4: Download the results

Once `status` is `complete`, request the download URLs. One call returns a URL for each output file in `files[]` and one URL for all results in `archiveUrl`. If the order has one output file, `archiveUrl` points to that file; if it has several, it points to a ZIP archive.

```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 and data.files[].downloadUrl are valid for data.expiresInSeconds seconds (up to 3600)
```

```jsx React theme={null}
// Request the URL right when the user clicks Download: it expires within an hour.
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']
```

**Response:**

```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[]` lists the same output files in the same order as `result.outputFiles[]`, with the same `fileId` values. Match them by `fileId` to see which watermark text each file carries.
* The URLs are valid for up to **3600 seconds**, or less if the retention period ends sooner. Request new URLs each time you download instead of storing them. After `expiresAt`, this endpoint returns `400 ORDER_EXPIRED`.

> [!NOTE]
>
> For a test order, `archiveUrl` and `files[]` point to fixed sample files (a ZIP when the order has several outputs). The samples are not your watermarked files.

## Full React example

Everything above, wired into one component: single-file upload with progress, a watermark text list with add/remove and duplicate detection, backoff polling, and a download button that fetches a fresh URL on each click. No dependencies beyond 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 } = {}) {
  // 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 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);
  // Lazy initializer: without it, crypto.randomUUID() would run on every render.
  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';

  // Flags every text after the first occurrence as a duplicate. Blank rows
  // are exempt — they're already caught by the "no blank text" rule below.
  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;

    // Snapshot the selection. The user can edit the form while requests are
    // in flight, and the upload must use the values as they were on submit.
    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);

      // No confirm step — processing starts automatically once the upload completes.
      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;
    }
  }

  // Download URLs expire within an hour, so fetch a fresh one on every click.
  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` 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.

## Error handling

| HTTP Status | Meaning | Action |
| - | - | - |
| `400` | Invalid request, `ORDER_NOT_COMPLETED` when you download an unfinished order, or `ORDER_EXPIRED` after the retention period | Check parameters and file extensions. Wait for `complete` before downloading. Expired orders cannot be downloaded |
| `401` | Authentication failed | Verify your API key |
| `409` | `WATERMARK_V2_CONFLICT`: the `idempotencyKey` was already used with a different request body | Use a new `idempotencyKey` for a new order |
| `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_V2_UNAVAILABLE`: watermark processing is temporarily unavailable | Retry later with the same `idempotencyKey` |

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

## Migrating from v2 to v3

The v3 request body has the same shape as v2, so most integrations only change the endpoint paths and how they read results.

> [!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-embed` | `POST /api/v3/orders/wtr-embed` |
| Request body | `files[].fileName`, `originalFileUrl`, `watermarks` | Same fields |
| Credits | — | 1 credit per watermark text, for images and PDFs alike |
| Results | One download for the whole order | One output file per watermark text, listed in `result.outputFiles[]` |
| Get the order | `GET /api/v2/orders/{orderId}` (deprecated) | [`GET /api/v3/orders/{orderId}`](https://docs.contentcare.ai/api-reference/orders/get-order-v3) |
| Download | `GET /api/v2/orders/{orderId}/download` (deprecated, URL valid for 7 days) | [`GET /api/v3/orders/{orderId}/download`](https://docs.contentcare.ai/api-reference/orders/download-order-v3): `archiveUrl` plus a URL for each output file (valid for up to 1 hour) |
| Upload URL | Valid for 1 hour; refresh with `refresh-urls` | Valid for 900 seconds; call `GET /api/v3/orders/{orderId}` for a fresh one |
| Confirm step | None | None |
| Webhooks | `order.watermarkEmbed.completed` / `failed` | Same event names and `data` fields |

Things that stay the same: images and PDFs can be mixed in one order, `originalFileUrl` still skips the upload for hosted files, and processing still starts without a confirm call.

> [!WARNING]
>
> Use the v3 endpoints to get and download orders. `GET /api/v3/orders/{orderId}` and `GET /api/v3/orders/{orderId}/download` work 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 its results.

**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 Extract](https://docs.contentcare.ai/quickstart/watermark-extract.md)**
>
> Check a file for a watermark and create a report.

> **[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 processing completes.

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


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