작업과 웹훅
미디어(동영상, 이미지, 음악) 생성은 비동기로 처리됩니다. 작업(job)을 생성한 다음 완료되면 결과를 가져옵니다. 작업 완료 여부를 확인하는 방법은 두 가지입니다. 폴링과 웹훅입니다. 아래에서는 작업의 처리 방식과 결과를 안정적으로 가져오는 방법을 설명합니다.
작업 수명 주기
POST /media/generate → status: processing ──► done (result_url 준비됨)
│ └─► failed (error, 비용 환불)
└── (선택 사항) callback_url → done/failed 시 푸시 웹훅- 생성. 모델과 매개변수를 포함해
POST /media/generate를 호출하면id와 폴링 URL이 반환됩니다.
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
"poll": "https://nordrouter.com/media/job/beefb531-…" }대기. 작업은
processing→done(또는failed) 상태로 진행됩니다. 이미지는 몇 초, 동영상과 음악은 수십 초에서 몇 분 정도 걸립니다.결과. 완료된 작업에는
result_url이 설정됩니다(nordrouter.com링크). 파일은 제한된 기간 동안 저장됩니다(GET /media/models의retention_days참조). 파일을 보관하려면 다운로드하세요.
GET /media/job/:id 응답 형식과 웹훅 본문 형식은 동일합니다(아래 참조).
{ "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }status—processing|done|failed.result_url—done일 때만 설정되며, 그 외에는null입니다.result_url_2— 음악 모델(Suno)의 두 번째 트랙이며, 그 외에는null입니다.results— 작업의 모든 파일입니다:[{ "index": 1, "name": "결과 1", "ext": "png", "url": "…" }](3D 작업은 모델과 미리보기, 트랙 분리 작업은 «보컬», «인스트루멘털» 등의 트랙을 포함합니다).cost_usd— 실제 비용입니다(사용한 만큼 청구되며, 생성 실패 시 요금이 청구되지 않고 예약 금액이 환불됩니다).error—failed상태일 때 오류 메시지이며, 그 외에는null입니다.
방법 1. 폴링
가장 간단한 방법은 status가 done 또는 failed가 될 때까지 GET /media/job/:id를 주기적으로 요청하는 것입니다.
curl https://nordrouter.com/media/job/beefb531-…2~3초 간격으로 폴링하세요. 폴링은 항상 사용할 수 있고 공개 주소가 필요하지 않아 스크립트, 노트북, CI에 적합합니다.
방법 2. 웹훅(push)
폴링을 사용하지 않으려면 POST /media/generate에 callback_url을 전달하세요. 작업이 완료되면 지정한 주소로 POST를 전송합니다.
curl https://nordrouter.com/media/generate \
-H "Authorization: Bearer sk-nr-YOUR-KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "image/nano-banana-2",
"input": { "prompt": "…" },
"callback_url": "https://your-server.com/webhooks/nordrouter",
"callback_secret": "임의의-문자열"
}'callback_url이 수락되면 생성 응답에 "webhook": "registered"가 포함됩니다. callback_url은 작업 생성 즉시 검증됩니다. 유효하지 않은 경우(https가 아니거나 비공개/로컬 주소인 경우) 요청은 사유와 함께 400을 반환하고({ "error": "callback_url 거부됨: …", "code": "invalid_callback" }), 작업은 생성되지 않습니다.
웹훅 본문
작업이 done 또는 failed 상태가 되면 GET /media/job/:id와 동일한 본문에 event 필드를 추가해 callback_url로 POST 요청을 보냅니다.
{ "event": "media.completed", "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }event—media.completed(성공) 또는media.failed(오류).
서명 확인
callback_secret을 지정하면 X-NR-Signature 헤더에 sha256=<hmac>가 포함됩니다. 이는 요청 원문 본문을 입력으로 하고 사용자의 비밀 키를 사용하는 HMAC-SHA256입니다. 요청이 위조가 아닌 실제 요청인지 확인하려면 서명을 검증하세요.
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header) # 상수 시간 비교import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}JSON을 파싱하기 전에 수신한 그대로의 바이트를 사용해 HMAC을 계산해야 합니다. 그렇지 않으면 서명이 일치하지 않습니다.
10줄로 만드는 웹훅 수신기
웹훅을 수신하고 서명을 확인한 뒤 2xx로 응답하는 서버입니다. callback_secret에 전달한 것과 동일한 비밀 값을 사용하세요.
Node.js / Express:
import express from "express";
import crypto from "node:crypto";
const SECRET = "임의의-문자열"; // = callback_secret 값
const app = express();
app.post("/webhooks/nordrouter",
express.raw({ type: "application/json" }), // 서명 검증에 원문 본문이 필요합니다
(req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const got = req.headers["x-nr-signature"] || "";
if (expected.length !== got.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got)))
return res.sendStatus(401); // 서명이 일치하지 않으면 요청을 거부합니다
const job = JSON.parse(req.body);
if (job.event === "media.completed") console.log("완료:", job.result_url, "$" + job.cost_usd);
res.sendStatus(200); // 2xx로 응답하지 않으면 재시도됩니다
});
app.listen(3000);Python / FastAPI:
import hmac, hashlib
from fastapi import FastAPI, Request, Response
SECRET = b"임의의-문자열" # = callback_secret 값
app = FastAPI()
@app.post("/webhooks/nordrouter")
async def hook(request: Request):
raw = await request.body() # 서명 검증에 원문 바이트가 필요합니다
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-NR-Signature", "")):
return Response(status_code=401) # 서명이 일치하지 않으면 요청을 거부합니다
job = await request.json()
if job["event"] == "media.completed":
print("완료:", job["result_url"], "$", job["cost_usd"])
return Response(status_code=200) # 2xx로 응답하지 않으면 재시도됩니다로컬에서 테스트하기
callback_url은 공개된 https 주소여야 합니다. 노트북에서 개발 중이라면 터널을 실행하세요. 예를 들어 ngrok http 3000을 실행한 다음 발급된 https 주소를 callback_url로 전달하면 됩니다.
전달 및 안정성
- 최대 4회 시도하며 대기 시간이 점차 늘어납니다(약 3 → 6 → 9초). 각 시도의 타임아웃은 15초입니다.
2xx로 응답하지 않으면 재시도됩니다. - 리디렉션은 따라가지 않습니다.
callback_url에서 직접 응답해야 합니다. - 전달은 best-effort입니다. 4회 시도가 모두 실패하면(서버가 중단된 경우 등) 웹훅을 나중에 다시 전송하지 않습니다. 따라서 다음 사항에 유의하세요.
웹훅은 폴링을 대체하지 않습니다
웹훅은 더 빠르게 알려 주는 수단이지 전달을 보장하지는 않습니다. 예비 경로로 GET /media/job/:id를 유지하세요. 합리적인 시간 내에 푸시가 도착하지 않으면 직접 상태를 폴링하세요. 멱등성은 사용자의 책임입니다. 같은 작업이 웹훅으로도 도착하고 폴링에서 확인될 수도 있으므로 id를 기준으로 처리하세요.
callback_url 요구 사항
https://만 허용됩니다.- 공개 주소만 허용됩니다. 로컬 및 비공개 주소(
localhost,*.local,10.x,172.16–31.x,192.168.x,127.x,169.254.x,100.64–127.x, IPv6 loopback/ULA/link-local)는 거부됩니다. 이는 SSRF 방지 조치입니다. DNS rebinding 방지를 위해 각 전송 전에도 주소를 다시 확인합니다.
무엇을 선택할까요
| 폴링 | 웹훅 | |
|---|---|---|
공개 https 주소 | 필요 없음 | 필요 |
| 결과 수신 지연 | 폴링 간격만큼 지연될 수 있음 | 거의 즉시 |
| 보장 수준 | 직접 제어 | best-effort + 예비 경로로 폴링 |
| 적합한 환경 | 스크립트, CI, 노트북 | 서버, 프로덕션 백엔드 |
권장: 백엔드에서는 웹훅과 예비 경로인 폴링을 함께 사용하고, 스크립트나 노트북에서는 폴링만 사용하세요.