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

# AI Detection

> 확률 점수로 AI 생성 이미지 탐지하기

이 가이드는 이미지가 AI로 생성되었는지 탐지하는 과정을 안내합니다 — 이미지 업로드부터 탐지 결과 확인까지.

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

## 사전 준비

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

> [!NOTE]
>
> 자동 발급된 [`sk_test_` 키](https://docs.contentcare.ai/ko/test-api-keys)로 실제 AI Detection 처리나 크레딧 사용 없이 주문 흐름을 검증할 수 있습니다. 테스트 업로드는 파일 내용을 폐기하고 결정적인 확률 값을 반환합니다. 실제 탐지 결과가 필요할 때는 일반 API 키를 사용하세요.

## 1단계: 주문 생성

이미지 파일 이름으로 AI Detection 주문을 생성합니다.

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

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

const orderIdempotencyKey = randomUUID();
const response = await fetch('https://api.bizmori.com/api/v2/orders/ai-detection', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_TOKEN',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    idempotencyKey: orderIdempotencyKey,
    fileName: 'photo.jpg',
  }),
});
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;
}

// `file`은 사용자가 선택한 단일 File 객체입니다
const order = await api('/orders/ai-detection', {
  method: 'POST',
  idempotencyKey: crypto.randomUUID(),
  body: { fileName: file.name },
});
```

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

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

**응답:**

```json theme={null}
{
  "data": {
    "orderId": "123456789",
    "orderName": "ai_detection_2026-03-18",
    "status": "pending",
    "file": {
      "fileId": 1,
      "fileName": "photo.jpg",
      "uploadUrl": "https://s3.amazonaws.com/...",
      "fileKey": "ai-detection/123456789/1/photo.jpg"
    }
  }
}
```

> [!TIP]
>
> 대시보드 구분을 위한 썸네일, 히트맵, 오버레이 이미지를 생성하려면 [AI Detection API 레퍼런스](https://docs.contentcare.ai/ko/api-reference/ai-detection/create-order.md)를 참고하세요.

## 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` 배열이 아니라 단일 `file`을 가지고 있습니다.
await putFile(file, order.file.uploadUrl, (ratio) => setProgress(ratio));
```

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

## 3단계: 주문 확인

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

```bash cURL theme={null}
CONFIRM_IDEMPOTENCY_KEY=$(uuidgen | tr '[:upper:]' '[:lower:]')
curl -X POST https://api.bizmori.com/api/v2/orders/ai-detection/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/ai-detection/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/ai-detection/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/ai-detection/confirm',
    headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
    json={'idempotencyKey': confirm_idempotency_key, 'orderId': '123456789'},
)
```

## 4단계: 결과 확인

> [!NOTE]
>
> 이 단계는 deprecated되었지만 동작이 바뀌지 않은 `GET /api/v2/orders/{orderId}`를 사용합니다. 새로 연동한다면 [주문 상세 조회(v3)](https://docs.contentcare.ai/ko/api-reference/orders/get-order-v3.md)를 사용하세요. 결과를 최상위 `statusCode`, `probability` 대신 `result.verdict`, `result.probability`로 반환합니다.

주문 상태를 폴링하거나 [웹훅](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.probability: 0-1
// data.statusCode: 'likely_ai' | 'uncertain_ai' | 'uncertain_real' | 'likely_real'
```

```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'},
)
data = res.json()['data']
probability = data['probability']
status_code = data['statusCode']
```

**응답:**

```json theme={null}
{
  "data": {
    "type": "aiDetection",
    "orderId": "123456789",
    "channel": "api",
    "thumbnailImageUrl": "https://s3.amazonaws.com/...",
    "status": "complete",
    "orderName": "ai_detection_2026-03-18",
    "fileCount": 1,
    "createdAt": "2026-03-18T12:00:00.000Z",
    "updatedAt": "2026-03-18T12:01:30.000Z",
    "errors": null,
    "probability": 0.92,
    "statusCode": "likely_ai",
    "options": {
      "generateHeatmap": false,
      "generateOverlay": false,
      "generateThumbnail": false
    },
    "originalImageUrl": "https://s3.amazonaws.com/..."
  }
}
```

> [!NOTE]
>
> `heatmapUrl`, `overlayUrl`은 주문 생성 시 각각 `options.generateHeatmap`, `options.generateOverlay`를 요청한 경우에만 응답에 포함됩니다.
> 결과 이미지는 보통 긴 변을 1600px 이내로 줄인 WebP입니다. `originalImageUrl`도 같은 방식으로 줄인 입력 이미지 사본이고, `thumbnailImageUrl`은 740×400 이내 썸네일이며 `options.generateThumbnail`과 관계없이 반환됩니다. 리사이즈 이미지가 준비되지 않았으면 원본 파일을 반환합니다.

### 결과 해석

`probability` 필드(0–1)는 이미지가 AI로 생성되었을 확률을 나타냅니다. `statusCode`는 이를 사람이 읽기 쉽게 해석한 값입니다:

| `statusCode` | 확률 구간 | 의미 |
| - | - | - |
| `likely_real` | \< 0.25 | 사람 창작 가능성 매우 높음 |
| `uncertain_real` | 0.25 – 0.5 | 사람 창작 가능성 높음 |
| `uncertain_ai` | 0.5 – 0.75 | AI 생성 가능성 높음 |
| `likely_ai` | ≥ 0.75 | AI 생성 가능성 매우 높음 |

## 전체 React 예제

위 내용을 모두 하나의 컴포넌트로 연결했습니다: 진행률 표시가 있는 이미지 업로드, 백오프 폴링, 그리고 탐지 결과 표시까지. React 외에 별도의 의존성은 없습니다.

```jsx AiDetectionChecker.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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function api(path, { method = 'GET', body, idempotencyKey } = {}) {
  // 재시도는 호출자의 idempotency 키를 재사용하므로, 재시도된 생성 요청이 두 번째
  // 주문을 만들어내는 일은 없습니다. 인증 실패는 `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 AiDetectionChecker() {
  const [file, setFile] = useState(null);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState('idle');
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const [orderId, setOrderId] = useState(null);
  const inFlight = useRef(false);

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

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

    // 선택한 파일을 스냅샷으로 저장합니다. 요청이 진행 중인 동안에도 사용자가 파일을
    // 바꿀 수 있으므로, `selected`는 자신이 생성한 주문과 계속 짝을 이루어야 합니다.
    const selected = file;
    setError(null);
    setOrderId(null);
    setResult(null);
    setProgress(0);

    try {
      setStatus('creating order');
      const order = await api('/orders/ai-detection', {
        method: 'POST',
        idempotencyKey: crypto.randomUUID(),
        body: {
          fileName: selected.name,
          options: { generateHeatmap: true, generateOverlay: true },
        },
      });

      setOrderId(order.orderId);
      setStatus('uploading');
      await putFile(selected, order.file.uploadUrl, setProgress);

      setStatus('confirming');
      await api('/orders/ai-detection/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()}`);

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

  return (
    <form onSubmit={detectImage}>
      <input
        type="file"
        accept={ACCEPT_FORMATS}
        disabled={busy}
        onChange={(event) => {
          setFile(event.target.files[0] ?? null);
          setError(null);
          setProgress(0);
          setResult(null);
        }}
      />
      <button type="submit" disabled={busy || !file}>
        이미지 탐지
      </button>

      {status !== 'idle' && <p>상태: {status}</p>}
      {status === 'uploading' && <p>업로드 진행률: {Math.round(progress * 100)}%</p>}

      {result && (
        <div>
          <p>
            확률: {Math.round(result.probability * 100)}% — {result.statusCode}
          </p>
          {result.heatmapUrl && <img src={result.heatmapUrl} alt="AI 탐지 히트맵" />}
          {result.overlayUrl && <img src={result.overlayUrl} alt="AI 탐지 오버레이" />}
        </div>
      )}
      {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) 페이지를 참고하세요.

## 다음 단계

> **[Anti-AI](https://docs.contentcare.ai/ko/quickstart/anti-ai.md)**
>
> 이미지를 AI 학습 및 생성으로부터 보호합니다.

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

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

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


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