Skip to content

任務與 Webhook ​

媒體生成(影片、圖片、音樂)採用非同步方式:您建立一個任務(job),並在完成後取得結果。有兩種方式可得知任務已完成:輪詢(polling)與 Webhook(push)。以下說明任務的運作方式,以及如何可靠地取得結果。

任務生命週期 ​

POST /media/generate  →  status: processing  ──►  done   (result_url 已就緒)
        │                                     └─►  failed (error,款項已退回)
        └── (選用) callback_url → 在 done/failed 時推送 Webhook
  1. 建立。 使用模型與參數呼叫 POST /media/generate,會回傳 id 與輪詢連結:
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 的回應格式(以及 Webhook 的本文,詳見下方)相同:

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. 輪詢(polling) ​

最簡單的方式是定期請求 GET /media/job/:id,直到 status 變為 done/failed:

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

每 2–3 秒輪詢一次。輪詢隨時可用,且不需要公開網址,適合用於指令碼、筆記本與 CI。

方法 2. Webhook(推送) ​

若不想輪詢,請在 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" }),且不會建立任務。

Webhook 本文 ​

當任務進入 done/failed 狀態時,您的 callback_url 會收到 POST,本文與 GET /media/job/:id 的回應相同,並多出 event 欄位:

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 行即可使用的接收端 ​

以下伺服器可接收 Webhook、驗證簽章並回應 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 次嘗試全部失敗(例如您的伺服器停機),之後不會重新傳送 Webhook。因此:

Webhook 無法取代輪詢

Webhook 可加快通知,但不保證送達。請保留 GET /media/job/:id 作為備援:若在合理時間內未收到推送,請自行輪詢狀態。您需要在接收端確保冪等性:同一任務可能透過 Webhook 送達,也可能由輪詢取得,請以 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 回送/ULA/連結本機位址)均會遭到拒絕,以防範 SSRF。每次傳送前也會重新檢查位址,以防範 DNS 重新綁定攻擊。

如何選擇 ​

輪詢Webhook
公開的 https 網址不需要需要
接收延遲最長為輪詢間隔幾乎即時
可靠性保證由您掌控best-effort + 輪詢備援
適用場景指令碼、CI、筆電伺服器、正式環境後端

**建議:**後端使用 Webhook,並以輪詢作為備援;指令碼/筆電則直接使用輪詢。