Skip to content

작업과 웹훅 ​

미디어(동영상, 이미지, 음악) 생성은 비동기로 처리됩니다. 작업(job)을 생성한 다음 완료되면 결과를 가져옵니다. 작업 완료 여부를 확인하는 방법은 두 가지입니다. 폴링과 웹훅입니다. 아래에서는 작업의 처리 방식과 결과를 안정적으로 가져오는 방법을 설명합니다.

작업 수명 주기 ​

POST /media/generate  →  status: processing  ──►  done   (result_url 준비됨)
        │                                     └─►  failed (error, 비용 환불)
        └── (선택 사항) callback_url → done/failed 시 푸시 웹훅
  1. 생성. 모델과 매개변수를 포함해 POST /media/generate를 호출하면 id와 폴링 URL이 반환됩니다.
json
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
  "poll": "https://nordrouter.com/media/job/beefb531-…" }
  1. 대기. 작업은 processing → done(또는 failed) 상태로 진행됩니다. 이미지는 몇 초, 동영상과 음악은 수십 초에서 몇 분 정도 걸립니다.

  2. 결과. 완료된 작업에는 result_url이 설정됩니다(nordrouter.com 링크). 파일은 제한된 기간 동안 저장됩니다(GET /media/models의 retention_days 참조). 파일을 보관하려면 다운로드하세요.

GET /media/job/:id 응답 형식과 웹훅 본문 형식은 동일합니다(아래 참조).

json
{ "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를 주기적으로 요청하는 것입니다.

bash
curl https://nordrouter.com/media/job/beefb531-…

2~3초 간격으로 폴링하세요. 폴링은 항상 사용할 수 있고 공개 주소가 필요하지 않아 스크립트, 노트북, CI에 적합합니다.

방법 2. 웹훅(push) ​

폴링을 사용하지 않으려면 POST /media/generate에 callback_url을 전달하세요. 작업이 완료되면 지정한 주소로 POST를 전송합니다.

bash
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 요청을 보냅니다.

json
{ "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입니다. 요청이 위조가 아닌 실제 요청인지 확인하려면 서명을 검증하세요.

python
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)  # 상수 시간 비교
javascript
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:

javascript
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:

python
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, 노트북서버, 프로덕션 백엔드

권장: 백엔드에서는 웹훅과 예비 경로인 폴링을 함께 사용하고, 스크립트나 노트북에서는 폴링만 사용하세요.