任务与 Webhook
媒体生成(视频、图像、音乐)是异步的:您创建一个任务(job),并在任务完成后获取结果。有两种方式可以获知任务已完成:轮询(polling)和 Webhook(push)。以下介绍任务的工作方式,以及如何可靠地获取结果。
任务生命周期
POST /media/generate → status: processing ──► done (result_url 已就绪)
│ └─► failed (error, 资金已退回)
└── (可选) callback_url → 在 done/failed 时推送 Webhook- 创建。 向
POST /media/generate提交模型和参数,返回id和轮询链接:
{ "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 的响应格式(以及下文 Webhook 的请求体格式)相同:
{ "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:
curl https://nordrouter.com/media/job/beefb531-…每 2–3 秒轮询一次。轮询始终可用,无需公开地址,适用于脚本、Notebook 和 CI。
方式 2. Webhook(推送)
无需轮询,只需在 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" }),且不会创建任务。
Webhook 请求体
当任务状态变为 done/failed 时,您的 callback_url 会收到一个 POST 请求,请求体与 GET /media/job/:id 的响应体相同,另加一个 event 字段:
{ "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 行即可搭建的接收端
以下服务器可接收 Webhook、验证签名并返回 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 次尝试均失败(例如您的服务器宕机),之后不会重新发送 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,并以轮询兜底;脚本或笔记本直接使用轮询。