媒体任务回调

媒体任务在完成、失败或取消后,可选的签名回调。

创建媒体任务时带上 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-Typeapplication/json
User-AgentHopsAPI-Webhook/1
webhook-id平台 task_id,用作幂等键。
webhook-timestamp这一次签名的 Unix 秒级时间戳。
webhook-signaturev1, 加上文签名算法得到的 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。

  1. 派生 signing_key = HMAC-SHA256(key = api_key, message = "hopsapi-webhook-v1")。结果是原始字节,不是 hex。
  2. 按顺序拼接 webhook-id、.、webhook-timestamp、. 和原始请求体字节。
  3. 计算 base64(HMAC-SHA256(key = signing_key, message = 上一步的字符串))。
  4. 用常量时间比较,对比 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", 200
import 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 Keysk-test-webhook-vector
webhook-idtsk_01HZTESTVECTOR
webhook-timestamp1760083200
原始请求体{"task_id":"tsk_01HZTESTVECTOR","status":2,"cost":1.25}
webhook-signaturev1,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,回调还没发出。
本页目录

On this page