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 秒轮询一次。轮询始终可用,无需公开地址,适用于脚本、Notebook 和 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 地址不需要需要
接收延迟最长为一个轮询间隔几乎即时
可靠性由您控制尽力而为 + 轮询备用
适用场景脚本、CI、笔记本服务器、生产环境后端

**建议:**后端使用 Webhook,并以轮询兜底;脚本或笔记本直接使用轮询。