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

# Anti-AI

> Anti-AI API로 이미지를 AI로부터 보호하기

이 가이드는 **Anti-AI 보호 주문**을 생성하는 과정을 안내합니다 — 이미지 업로드부터 보호된 결과물 다운로드까지.

이 문서의 모든 엔드포인트는 순수 HTTPS와 JSON 통신이므로, 서버뿐 아니라 브라우저에서도 동일하게 호출할 수 있습니다. 각 단계에는 **React** 탭이 포함되어 있으며, [전체 복사-붙여넣기용 컴포넌트](#전체-react-예제)는 페이지 하단에서 확인할 수 있습니다.

## 사전 준비

* BIZ MORI API 키 ([여기서 발급](https://app.contentcare.ai/keys))
* 보호할 이미지 파일 (`jpeg`, `jpg`, `png`, `webp`, `tiff`, 또는 `bmp`)

> [!NOTE]
>
> 자동 발급된 [`sk_test_` 키](https://docs.contentcare.ai/ko/test-api-keys)로 실제 Anti-AI 처리를 실행하거나 크레딧을 사용하지 않고 주문 흐름을 검증할 수 있습니다. 테스트 업로드는 파일 내용을 폐기하며 다운로드 가능한 결과를 만들지 않습니다. 실제 보호 이미지를 얻으려면 일반 API 키를 사용하세요.

## 1단계: 주문 생성

주문을 생성하고 S3에 파일을 업로드하기 위한 Presigned URL을 받습니다.

> [!TIP]
>
> 파일을 업로드하는 대신 이미지 URL을 직접 제공할 수도 있습니다. URL 모드에 대한 자세한 내용은 [Anti-AI 주문 생성 API](https://docs.contentcare.ai/ko/api-reference/anti-ai/create-order.md)를 참고하세요.

```bash cURL theme={null}
ORDER_IDEMPOTENCY_KEY=$(uuidgen | tr '[:upper:]' '[:lower:]')
curl -X POST https://api.bizmori.com/api/v2/orders/anti-ai \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "idempotencyKey": "'$ORDER_IDEMPOTENCY_KEY'",
    "files": [{ "fileName": "photo.jpg" }],
    "options": { "strength": "high" }
  }'
```

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

const orderIdempotencyKey = randomUUID();
const response = await fetch('https://api.bizmori.com/api/v2/orders/anti-ai', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    idempotencyKey: orderIdempotencyKey,
    files: [{ fileName: 'photo.jpg' }],
    options: { strength: 'high' },
  }),
});
const { data } = await response.json();
```

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

async function api(path, { method = 'GET', body, idempotencyKey } = {}) {
  const res = await fetch(`${API_BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${API_TOKEN}`,
      ...(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;
}

// `files`는 사용자가 선택한 File[] 입니다.
const order = await api('/orders/anti-ai', {
  method: 'POST',
  idempotencyKey: crypto.randomUUID(),
  body: {
    files: files.map((file) => ({ fileName: file.name })),
    options: { strength: 'high' },
  },
});
```

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

order_idempotency_key = str(uuid.uuid4())
res = requests.post(
    'https://api.bizmori.com/api/v2/orders/anti-ai',
    headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
    json={
        'idempotencyKey': order_idempotency_key,
        'files': [{'fileName': 'photo.jpg'}],
        'options': {'strength': 'high'},
    },
)
data = res.json()['data']
```

**응답:**

```json theme={null}
{
  "data": {
    "orderName": "anti_ai_2026-02-19",
    "orderId": "123456789",
    "status": "pending",
    "files": [
      {
        "fileId": 1,
        "fileName": "photo.jpg",
        "uploadUrl": "https://s3.amazonaws.com/...",
        "fileKey": "temp/123456789/0/photo.jpg"
      }
    ]
  }
}
```

## 2단계: 파일 업로드

1단계 응답의 `uploadUrl`로 파일을 PUT 업로드합니다. **Authorization 헤더는 필요 없습니다** — S3 직접 업로드입니다.

```bash cURL theme={null}
curl -X PUT "https://s3.amazonaws.com/..." \
  -H "Content-Type: image/jpeg" \
  --data-binary @photo.jpg
```

```jsx React theme={null}
// 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', file.type);

    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[index]`는
// `order.files[index].uploadUrl`에 대응합니다.
await Promise.all(
  files.map((file, index) =>
    putFile(file, order.files[index].uploadUrl, (ratio) =>
      setProgress((prev) => ({ ...prev, [index]: ratio })),
    ),
  ),
);
```

> [!NOTE]
>
> live 키는 S3 presigned URL을 받고, 테스트 키는 `https://api.bizmori.com/api/v2/test-uploads/{signedToken}`을 받습니다. 두 URL 모두 **1시간** 후 만료되며 같은 방식으로 Authorization 헤더 없이 PUT합니다. 테스트 업로드는 스트림으로만 소비되고 저장·처리되지 않습니다. 만료되면 [URL 갱신](https://docs.contentcare.ai/ko/api-reference/orders/refresh-urls.md)을 사용하세요.

## 3단계: 주문 확인

모든 파일 업로드 완료 후, 처리를 시작하기 위해 주문을 확인합니다:

```bash cURL theme={null}
CONFIRM_IDEMPOTENCY_KEY=$(uuidgen | tr '[:upper:]' '[:lower:]')
curl -X POST https://api.bizmori.com/api/v2/orders/anti-ai/confirm \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"idempotencyKey\": \"$CONFIRM_IDEMPOTENCY_KEY\", \"orderId\": \"123456789\"}"
```

```javascript Node.js theme={null}
const confirmIdempotencyKey = randomUUID();
await fetch('https://api.bizmori.com/api/v2/orders/anti-ai/confirm', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    idempotencyKey: confirmIdempotencyKey,
    orderId: '123456789',
  }),
});
```

```jsx React theme={null}
await api('/orders/anti-ai/confirm', {
  method: 'POST',
  idempotencyKey: crypto.randomUUID(),
  body: { orderId: order.orderId },
});
```

```python Python theme={null}
confirm_idempotency_key = str(uuid.uuid4())
requests.post(
    'https://api.bizmori.com/api/v2/orders/anti-ai/confirm',
    headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
    json={'idempotencyKey': confirm_idempotency_key, 'orderId': '123456789'},
)
```

## 4단계: 주문 상태 확인

> [!NOTE]
>
> 4·5단계는 deprecated되었지만 동작이 바뀌지 않은 `GET /api/v2/orders/{orderId}`와 `GET /api/v2/orders/{orderId}/download`를 사용합니다. 새로 연동한다면 [주문 상세 조회(v3)](https://docs.contentcare.ai/ko/api-reference/orders/get-order-v3.md)와 [주문 결과 다운로드 URL 발급(v3)](https://docs.contentcare.ai/ko/api-reference/orders/download-order-v3.md)을 사용하세요. 모든 주문 종류를 한 가지 형식으로 반환하며, v3 다운로드는 결과 파일별 URL과 결과 전체 ZIP을 함께 반환합니다.

주문 상태를 폴링하거나 [웹훅](https://docs.contentcare.ai/ko/webhooks.md)을 설정하여 처리 완료 알림을 받습니다.

```bash cURL theme={null}
curl https://api.bizmori.com/api/v2/orders/123456789 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
```

```javascript Node.js theme={null}
const res = await fetch('https://api.bizmori.com/api/v2/orders/123456789', {
  headers: { 'Authorization': 'Bearer YOUR_API_TOKEN' },
});
const { data } = await res.json();
// data.status: 'pending' | 'inProgress' | 'complete' | 'failed'
```

```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}
res = requests.get(
    'https://api.bizmori.com/api/v2/orders/123456789',
    headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
)
status = res.json()['data']['status']
```

**응답:**

```json theme={null}
{
  "data": {
    "type": "antiAi",
    "orderId": "123456789",
    "channel": "api",
    "thumbnailImageUrl": "https://s3.amazonaws.com/...",
    "status": "complete",
    "orderName": "anti_ai_2026-03-18",
    "fileCount": 1,
    "createdAt": "2026-03-18T12:00:00.000Z",
    "updatedAt": "2026-03-18T12:01:30.000Z",
    "errors": null
  }
}
```

주문 상태 흐름:

| 상태 | 의미 |
| - | - |
| `pending` | 파일 업로드 대기 중 |
| `inProgress` | 처리 중 |
| `complete` | 완료 (다운로드 가능) |
| `failed` | 처리 실패 |

## 5단계: 결과 다운로드

상태가 `complete`이면 다운로드 URL을 받습니다:

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

```javascript Node.js theme={null}
const res = await fetch('https://api.bizmori.com/api/v2/orders/123456789/download', {
  headers: { 'Authorization': 'Bearer YOUR_API_TOKEN' },
});
const { data } = await res.json();
// data.url — 7일 유효
```

```jsx React theme={null}
const { url } = await api(`/orders/${order.orderId}/download`);
// <a href={url} download> 요소에 전달하세요 — URL은 7일 동안 유효합니다.
setDownloadUrl(url);
```

```python Python theme={null}
res = requests.get(
    'https://api.bizmori.com/api/v2/orders/123456789/download',
    headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
)
download_url = res.json()['data']['url']
```

**응답:**

```json theme={null}
{
  "data": {
    "url": "https://s3.amazonaws.com/..."
  }
}
```

`url`은 **7일** 유효한 Presigned S3 URL입니다. 이 URL로 보호된 파일을 직접 다운로드하세요.

> [!NOTE]
>
> 테스트 주문은 결과 파일을 만들지 않습니다. `downloadUrl`은 `null`이고 다운로드 엔드포인트는 `PROCESSED_FILE_NOT_FOUND`를 반환할 수 있습니다.

Anti-AI API에서 지원하는 옵션과 상세한 설명은 [API 레퍼런스](https://docs.contentcare.ai/ko/api-reference/anti-ai/create-order.md)를 참고하세요.

## 전체 React 예제

위 내용을 모두 하나의 컴포넌트로 연결했습니다: 다중 파일 업로드, 파일별 업로드 진행률, 백오프 폴링, 다운로드 링크까지. React 외에 별도의 의존성은 없습니다.

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

const API_BASE = 'https://api.bizmori.com/api/v2';
const API_TOKEN = import.meta.env.VITE_MORI_API_TOKEN;

const ACCEPT_FORMATS = '.jpg,.jpeg,.png,.webp,.tiff,.bmp';
const MAX_FILES = 100;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

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

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

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

    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 AntiAiUploader() {
  const [files, setFiles] = useState([]);
  const [progress, setProgress] = useState({});
  const [status, setStatus] = useState('idle');
  const [downloadUrl, setDownloadUrl] = useState(null);
  const [error, setError] = useState(null);
  const [orderId, setOrderId] = useState(null);
  const inFlight = useRef(false);

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

  async function protectImages(event) {
    event.preventDefault();
    if (inFlight.current || files.length === 0) return;
    inFlight.current = true;

    // 선택 항목을 스냅샷으로 고정합니다. 요청이 진행되는 동안 사용자가 파일을
    // 바꿀 수 있으므로, selected[i]는 uploadUrls[i]와 계속 짝을 이뤄야 합니다.
    const selected = files;
    setError(null);
    setOrderId(null);
    setDownloadUrl(null);
    setProgress({});

    try {
      setStatus('creating order');
      const order = await api('/orders/anti-ai', {
        method: 'POST',
        idempotencyKey: crypto.randomUUID(),
        body: {
          files: selected.map((file) => ({ fileName: file.name })),
          options: { strength: 'high' },
        },
      });

      setOrderId(order.orderId);
      setStatus('uploading');
      await Promise.all(
        selected.map((file, index) =>
          putFile(file, order.files[index].uploadUrl, (ratio) =>
            setProgress((prev) => ({ ...prev, [index]: ratio })),
          ),
        ),
      );

      setStatus('confirming');
      await api('/orders/anti-ai/confirm', {
        method: 'POST',
        idempotencyKey: crypto.randomUUID(),
        body: { orderId: order.orderId },
      });

      const finished = await pollOrder(order.orderId, { onStatus: setStatus });
      // `expired`도 종료 상태입니다. 정리 작업이 7일 후 파일을 삭제합니다.
      if (finished.status !== 'complete') throw new Error(`ORDER_${finished.status.toUpperCase()}`);

      setStatus('fetching download URL');
      const { url } = await api(`/orders/${order.orderId}/download`);
      setDownloadUrl(url);
      setStatus('complete');
    } catch (caught) {
      setError(caught.message);
      setStatus('idle');
    } finally {
      inFlight.current = false;
    }
  }

  return (
    <form onSubmit={protectImages}>
      <input
        type="file"
        multiple
        accept={ACCEPT_FORMATS}
        disabled={busy}
        onChange={(event) => {
          setFiles([...event.target.files].slice(0, MAX_FILES));
          setDownloadUrl(null);
          setError(null);
          setProgress({});
        }}
      />
      <button type="submit" disabled={busy || files.length === 0}>
        이미지 {files.length}개 보호하기
      </button>

      {status !== 'idle' && <p>상태: {status}</p>}

      <ul>
        {files.map((file, index) => (
          <li key={`${file.name}-${index}`}>
            {file.name} — {Math.round((progress[index] ?? 0) * 100)}%
          </li>
        ))}
      </ul>

      {downloadUrl && (
        <a href={downloadUrl} download>
          보호된 이미지 다운로드
        </a>
      )}
      {error && <p role="alert">실패: {error}{orderId && ` (orderId: ${orderId})`}</p>}
    </form>
  );
}
```

> [!NOTE]
>
> `import.meta.env.VITE_MORI_API_TOKEN`은 Vite 문법입니다. Next.js에서는 `process.env.NEXT_PUBLIC_MORI_API_TOKEN`을 사용하거나, 사용 중인 번들러가 클라이언트 코드에 노출하는 방식을 따르세요. `crypto.randomUUID()`는 보안 컨텍스트에서만 동작합니다. HTTPS와 `localhost`에서는 문제없지만, 일반 HTTP LAN 주소에서는 사용할 수 없습니다.

## 에러 처리

| HTTP 상태 코드 | 의미 | 조치 |
| - | - | - |
| `400` | 잘못된 요청 | 파라미터 및 파일 형식 확인 |
| `401` | 인증 실패 | API 키 확인 |
| `429` | 레이트 리밋, 또는 요금제 사용량 소진 시 `PLAN_LIMIT_EXCEEDED` | `code` 값을 확인하세요. 레이트 리밋은 재시도로 풀리지만 `PLAN_LIMIT_EXCEEDED`는 풀리지 않으므로 요금제를 업그레이드해야 합니다 |

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

## 다음 단계

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

> **[워터마크 검출](https://docs.contentcare.ai/ko/quickstart/watermark-extract.md)**
>
> 이미지에서 비가시성 워터마크를 검출하고 추출합니다.

> **[AI Detection](https://docs.contentcare.ai/ko/quickstart/ai-detection.md)**
>
> AI 생성 이미지를 확률 점수로 탐지합니다.

> **[웹훅](https://docs.contentcare.ai/ko/webhooks.md)**
>
> 처리 완료 시 알림을 받기 위해 웹훅을 설정합니다.


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