사전 준비
- BIZ MORI API 키 (여기서 발급)
- 비가시성 워터마크를 삽입할 파일 (
jpeg,jpg,png,webp,tiff,bmp, 또는pdf)
자동 발급된
sk_test_ 키로 실제 워터마크 처리를 실행하거나 크레딧을 사용하지 않고 주문 흐름을 검증할 수 있습니다. 테스트 업로드는 파일 내용을 폐기하며 다운로드 가능한 결과를 만들지 않습니다. 실제 워터마크 파일을 얻으려면 일반 API 키를 사용하세요.1단계: 주문 생성
워터마크 텍스트와 함께 비가시성 워터마크 삽입 주문을 생성합니다.ORDER_IDEMPOTENCY_KEY=$(uuidgen | tr '[:upper:]' '[:lower:]')
curl -X POST https://api.bizmori.com/api/v2/orders/wtr-embed \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "'$ORDER_IDEMPOTENCY_KEY'",
"files": [
{
"fileName": "photo.jpg",
"watermarks": [{ "text": "MORI_WATERMARK" }]
}
]
}'
import { randomUUID } from 'node:crypto';
const orderIdempotencyKey = randomUUID();
const response = await fetch('https://api.bizmori.com/api/v2/orders/wtr-embed', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json',
},
body: JSON.stringify({
idempotencyKey: orderIdempotencyKey,
files: [
{
fileName: 'photo.jpg',
watermarks: [{ text: 'MORI_WATERMARK' }],
},
],
}),
});
const { data } = await response.json();
// JSON을 사용하는 모든 호출에 쓰는 헬퍼 함수 하나입니다. API는 실패 시
// `{ code: "ERROR_CODE" }`를 반환하므로, 단순 상태 코드 대신 이 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, `watermarkTexts`는 string[]입니다
const order = await api('/orders/wtr-embed', {
method: 'POST',
idempotencyKey: crypto.randomUUID(),
body: {
files: [
{
fileName: file.name,
watermarks: watermarkTexts.map((text) => ({ text })),
},
],
},
});
import uuid
import requests
order_idempotency_key = str(uuid.uuid4())
res = requests.post(
'https://api.bizmori.com/api/v2/orders/wtr-embed',
headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
json={
'idempotencyKey': order_idempotency_key,
'files': [
{
'fileName': 'photo.jpg',
'watermarks': [{'text': 'MORI_WATERMARK'}],
}
],
},
)
data = res.json()['data']
{
"data": {
"orderName": "wtr_embed_2026-03-18",
"orderId": "123456789",
"status": "pending",
"files": [
{
"fileId": 1,
"fileName": "photo.jpg",
"uploadUrl": "https://s3.amazonaws.com/...",
"fileKey": "wtr-embed/123456789/images/1/photo.jpg",
"fileFormat": "JPG",
"fileType": "IMG"
}
]
}
}
2단계: 파일 업로드
1단계 응답의uploadUrl로 파일을 PUT 업로드합니다. Authorization 헤더는 필요 없습니다 — S3 직접 업로드입니다.
curl -X PUT "https://s3.amazonaws.com/..." \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
// 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);
});
}
// 파일이 정확히 하나이므로, 업로드 진행률은 숫자 하나로 표현됩니다.
await putFile(file, order.files[0].uploadUrl, (ratio) => setProgress(ratio));
별도의 확인(confirm) 단계가 필요 없습니다. Anti-AI 및 AI Detection 서비스와 달리, 비가시성 워터마크 삽입은 파일 업로드가 완료되면 처리가 자동으로 시작됩니다. 업로드 후 바로 주문 상태를 확인하시면 됩니다.
live 키는 S3 presigned URL을 받고, 테스트 키는
https://api.bizmori.com/api/v2/test-uploads/{signedToken}을 받습니다. 두 URL 모두 1시간 후 만료되며 같은 방식으로 Authorization 헤더 없이 PUT합니다. 테스트 업로드는 스트림으로만 소비되고 저장·처리되지 않습니다. 만료되면 URL 갱신을 사용하세요.3단계: 주문 상태 확인 & 결과 다운로드
주문 상태를 폴링하거나 웹훅을 설정하여 처리 완료 알림을 받습니다.curl https://api.bizmori.com/api/v2/orders/123456789 \
-H "Authorization: Bearer YOUR_API_TOKEN"
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'
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');
}
res = requests.get(
'https://api.bizmori.com/api/v2/orders/123456789',
headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
)
status = res.json()['data']['status']
{
"data": {
"type": "watermarkEmbed",
"orderId": "123456789",
"channel": "api",
"thumbnailImageUrl": "https://s3.amazonaws.com/...",
"status": "complete",
"orderName": "wtr_embed_2026-03-18",
"fileCount": 1,
"createdAt": "2026-03-18T12:00:00.000Z",
"updatedAt": "2026-03-18T12:01:30.000Z",
"errors": null,
"watermarks": ["MORI_WATERMARK"]
}
}
| 상태 | 의미 |
|---|---|
pending | 파일 업로드 대기 중 |
inProgress | 처리 중 |
complete | 완료 (다운로드 가능) |
failed | 처리 실패 |
complete이면 다운로드 URL을 받습니다:
curl https://api.bizmori.com/api/v2/orders/123456789/download \
-H "Authorization: Bearer YOUR_API_TOKEN"
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일 유효
const { url } = await api(`/orders/${order.orderId}/download`);
// <a href={url} download>에 전달하세요 — 이 URL은 7일 동안 유효합니다
setDownloadUrl(url);
res = requests.get(
'https://api.bizmori.com/api/v2/orders/123456789/download',
headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
)
download_url = res.json()['data']['url']
{
"data": {
"url": "https://s3.amazonaws.com/..."
}
}
url은 7일 유효한 Presigned S3 URL입니다.
테스트 주문은 결과 파일을 만들지 않습니다.
downloadUrl은 null이고 다운로드 엔드포인트는 PROCESSED_FILE_NOT_FOUND를 반환할 수 있습니다.전체 React 예제
위 내용을 모두 하나의 컴포넌트로 연결했습니다: 진행률을 보여주는 단일 파일 업로드, 추가/삭제와 중복 검사가 가능한 워터마크 텍스트 목록, 백오프 폴링, 다운로드 링크까지. React 외에 별도의 의존성은 없습니다.WatermarkEmbedUploader.jsx
import { useMemo, 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,.pdf';
const MAX_WATERMARKS = 10;
const MAX_TEXT_LENGTH = 1000;
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 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 [downloadUrl, setDownloadUrl] = useState(null);
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 &&
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() {
if (watermarks.length >= MAX_WATERMARKS) return;
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);
setDownloadUrl(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 });
// `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={embedWatermarks}>
<input
type="file"
accept={ACCEPT_FORMATS}
disabled={busy}
onChange={(event) => {
setFile(event.target.files[0] ?? null);
setError(null);
setProgress(0);
setDownloadUrl(null);
}}
/>
<ul>
{watermarks.map((watermark, index) => (
<li key={watermark.id}>
<input
type="text"
value={watermark.text}
maxLength={MAX_TEXT_LENGTH}
disabled={busy}
placeholder={`워터마크 텍스트 #${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)}>
삭제
</button>
</li>
))}
</ul>
<button type="button" disabled={busy || watermarks.length >= MAX_WATERMARKS} onClick={addWatermark}>
워터마크 텍스트 추가 ({watermarks.length}/{MAX_WATERMARKS})
</button>
<button type="submit" disabled={!canSubmit}>
워터마크 삽입
</button>
{status !== 'idle' && <p>상태: {status}</p>}
{status === 'uploading' && <p>업로드 진행률: {Math.round(progress * 100)}%</p>}
{downloadUrl && (
<a href={downloadUrl} download>
워터마크 삽입 이미지 다운로드
</a>
)}
{error && <p role="alert">실패: {error}{orderId && ` (orderId: ${orderId})`}</p>}
</form>
);
}
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는 풀리지 않으므로 요금제를 업그레이드해야 합니다 |
다음 단계
Anti-AI
이미지를 AI 학습 및 생성으로부터 보호합니다.
워터마크 검출
이미지에서 비가시성 워터마크를 검출하고 추출합니다.
AI Detection
AI 생성 이미지를 확률 점수로 탐지합니다.
웹훅
처리 완료 시 알림을 받기 위해 웹훅을 설정합니다.