媒体任务回调
媒体任务在完成、失败或取消后,可选的签名回调。
创建媒体任务时带上 callback_url,U-Token 通用币 会在任务结束时把终态结果 POST 到你的服务器。GET /v1/router/tasks/{task_id} 轮询始终可用。收不到回调,或每次推送都失败时,用轮询兜底。
开启方式
callback_url 是 POST /v1/router/task/create 的可选参数。不传时行为和现在一样,不会推送。
curl https://u-token.ai/v1/router/task/create \
-H "Authorization: Bearer sk-你的KEY" \
-H "content-type: application/json" \
-d '{
"model": "grok-imagine-video",
"task": "Txt2Video",
"mode": "fast",
"callback_url": "https://example.com/hooks/media",
"input": { "prompt": "一只走路的猫", "duration": 5, "resolution": "720p", "aspect_ratio": "16:9" }
}'{ "code": 200, "message": "success", "task_id": "tsk_0f3c9a72b1e04d5f" }地址必须满足:
- 公网 HTTPS(只有调用本地部署时才接受 HTTP)
- 最长 2048 个字符
- 不带用户名和密码
- 不能是回环、内网、链路本地或元数据地址,包括
localhost
不满足时直接拒绝,不建任务、不扣费:
{
"error": {
"message": "callback_url must be a public https URL",
"type": "invalid_request_error",
"code": "invalid_callback_url"
}
}何时推送
任务进入终态,并且扣费已经完成之后才会推送:
| 状态 | 含义 |
|---|---|
2 | 成功。正文里的 cost 就是最终扣费。 |
3 | 失败。不会结算扣费。 |
4 | 已取消。不会结算扣费。 |
每个任务成功送达一次。之后重复进入终态不会再推一次成功回调。
回调请求
POST https://example.com/hooks/media
Content-Type: application/json
User-Agent: HopsAPI-Webhook/1
webhook-id: tsk_0f3c9a72b1e04d5f
webhook-timestamp: 1760083200
webhook-signature: v1,example-signature| 请求头 | 含义 |
|---|---|
Content-Type | application/json |
User-Agent | HopsAPI-Webhook/1 |
webhook-id | 平台 task_id,用作幂等键。 |
webhook-timestamp | 这一次签名的 Unix 秒级时间戳。 |
webhook-signature | v1, 加上文签名算法得到的 base64。 |
请求头名称遵循 Standard Webhooks。请求体与此刻 GET /v1/router/tasks/{task_id} 的 JSON 一致,带的是你的价格,不含上游成本。
成功:
{
"task_id": "tsk_0f3c9a72b1e04d5f",
"status": 2,
"results": [{ "urls": ["https://cdn.example/video.mp4"] }],
"cost": 1.25
}失败:
{
"code": 424,
"message": "Provider error (request id: aitk-0123456789abcdef01234567)",
"task_id": "tsk_0f3c9a72b1e04d5f",
"status": 3,
"fail_reason": "Provider error (request id: aitk-0123456789abcdef01234567)",
"error_message": "Provider error (request id: aitk-0123456789abcdef01234567)"
}取消:
{
"task_id": "tsk_0f3c9a72b1e04d5f",
"status": 4,
"message": "cancelled"
}不同模型的字段不完全相同。除了 task_id 和 status,以查询接口当时的响应为准。
验签
签名用的是创建该任务的那把 API Key。之后换 Key 或删 Key,都不影响已经创建的任务;验签必须仍用创建时的那把 Key。
- 派生
signing_key = HMAC-SHA256(key = api_key, message = "hopsapi-webhook-v1")。结果是原始字节,不是 hex。 - 按顺序拼接
webhook-id、.、webhook-timestamp、.和原始请求体字节。 - 计算
base64(HMAC-SHA256(key = signing_key, message = 上一步的字符串))。 - 用常量时间比较,对比
webhook-signature里v1,后面的值。
必须用收到的原始请求体字节计算。先解析 JSON 再序列化,签名会对不上。时间戳与当前时间相差超过 5 分钟应拒绝,避免重放。验签是可选的。不验签就无法确认请求来自 U-Token 通用币。
Node.js(Express)。安装 express 后用 node 运行:
import crypto from 'node:crypto';
import express from 'express';
const apiKey = process.env.API_KEY;
if (!apiKey) throw new Error('Set API_KEY to the key that created the task');
const app = express();
app.post('/hooks/media', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
const id = req.get('webhook-id') ?? '';
const timestamp = req.get('webhook-timestamp') ?? '';
const header = req.get('webhook-signature') ?? '';
const timestampSec = Number(timestamp);
const fresh =
Number.isInteger(timestampSec) && Math.abs(Math.floor(Date.now() / 1000) - timestampSec) <= 300;
const signingKey = crypto.createHmac('sha256', apiKey).update('hopsapi-webhook-v1').digest();
const expected = crypto
.createHmac('sha256', signingKey)
.update(`${id}.${timestamp}.${rawBody}`)
.digest('base64');
const presented = header.startsWith('v1,') ? header.slice(3) : '';
const left = Buffer.from(expected);
const right = Buffer.from(presented);
const signed = fresh && left.length === right.length && crypto.timingSafeEqual(left, right);
if (!signed) {
res.status(401).send('invalid signature');
return;
}
res.status(200).send('ok');
queueMicrotask(() => {
const task = JSON.parse(rawBody);
console.log(task.task_id, task.status);
});
});
app.listen(8080);Python(Flask 与 FastAPI)。签名用 request.get_data() 或 await request.body() 的原始字节,不要用重新序列化的 dict。
import base64, hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
API_KEY = os.environ["API_KEY"]
def signed(raw: bytes) -> bool:
timestamp = request.headers.get("webhook-timestamp", "")
header = request.headers.get("webhook-signature", "")
try:
timestamp_sec = int(timestamp)
except ValueError:
return False
if abs(int(time.time()) - timestamp_sec) > 300 or not header.startswith("v1,"):
return False
signing_key = hmac.new(API_KEY.encode(), b"hopsapi-webhook-v1", hashlib.sha256).digest()
message = b".".join([request.headers.get("webhook-id", "").encode(), timestamp.encode(), raw])
expected = base64.b64encode(hmac.new(signing_key, message, hashlib.sha256).digest()).decode()
return hmac.compare_digest(expected, header[3:])
@app.post("/hooks/media")
def hook():
raw = request.get_data()
if not signed(raw):
return "invalid signature", 401
return "ok", 200import base64, hashlib, hmac, os, time
from fastapi import FastAPI, Request
from fastapi.responses import Response
app = FastAPI()
API_KEY = os.environ["API_KEY"]
@app.post("/hooks/media")
async def hook(request: Request):
raw = await request.body()
timestamp = request.headers.get("webhook-timestamp", "")
header = request.headers.get("webhook-signature", "")
webhook_id = request.headers.get("webhook-id", "")
try:
timestamp_sec = int(timestamp)
except ValueError:
return Response(status_code=401)
if abs(int(time.time()) - timestamp_sec) > 300 or not header.startswith("v1,"):
return Response(status_code=401)
signing_key = hmac.new(API_KEY.encode(), b"hopsapi-webhook-v1", hashlib.sha256).digest()
message = b".".join([webhook_id.encode(), timestamp.encode(), raw])
expected = base64.b64encode(hmac.new(signing_key, message, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(expected, header[3:]):
return Response(status_code=401)
return Response(status_code=200)固定测试向量。请求体正好是下面这一行,不能多空格或换行:
| 字段 | 值 |
|---|---|
| API Key | sk-test-webhook-vector |
webhook-id | tsk_01HZTESTVECTOR |
webhook-timestamp | 1760083200 |
| 原始请求体 | {"task_id":"tsk_01HZTESTVECTOR","status":2,"cost":1.25} |
webhook-signature | v1,rolTwOWmAdIbYmiLFV12wYXqnYP7qrPAgIyjJHxwyOU= |
如何响应
10 秒内返回任意 HTTP 2xx。响应体会被忽略。建议先返回 2xx,再做耗时处理。3xx 不会被跟随,按失败处理。
重试
非 2xx、超时或连接失败都算失败。U-Token 通用币 会在 1 分钟、5 分钟、30 分钟后重试。4 次都失败后不再推送,需要结果时改查任务。
每次重试都会重新签名,时间戳也会变。请按 webhook-id 去重,避免同一次任务被处理两遍。
收不到回调时
- 地址是公网 HTTPS,并且没有指向内网。
- 服务器在 10 秒内返回了
2xx。 - 防火墙没有丢掉这个 POST。
GET /v1/router/tasks/{task_id}已经是状态2、3或4。如果还是0或1,回调还没发出。
