回调(Webhooks)
任务成功或失败时,在你的 HTTPS 地址收到通知。
创建任务时传入 webhook_url,任务结束后(无论成功还是失败),NanoAPI 会向它发送一次 POST。没传 webhook_url 的任务不发回调。
回调遵循 Standard Webhooks 规范,可以直接用其官方库验证签名。验证是可选的。
事件
| 事件 | 触发时机 |
|---|---|
task.succeeded | 任务成功 |
task.failed | 任务失败(包括排队超时) |
每个任务只会发出其中一个事件。开始生成时没有事件;需要显示进度时,用 GET /v1/tasks/{id} 读取 status。
请求格式
POST <webhook_url>
Content-Type: application/json
webhook-id: ev_01jb9a3k7m2n5p8q4r6s1t0v9w
webhook-timestamp: 1792658832
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
User-Agent: Nanofield-Webhooks/1.0{
"type": "task.succeeded",
"timestamp": "2026-10-22T08:47:12Z",
"data": {
"object": "task",
"id": "tk_01jb8z5q4w3e2r1t0y9u8i7o6p",
"status": "succeeded",
"outputs": [{ "index": 0, "type": "video", "url": "https://file.example.com/…/0.mp4", "preview_url": "https://file.example.com/…/0_compressed.mp4" }],
"billing": { "currency": "USD", "frozen": "4.095", "charged": "4.095", "refunded": "0.00", "billed_seconds": 15 },
"metadata": { "order_id": "A1001" }
}
}| 字段或请求头 | 说明 |
|---|---|
type | 事件类型 |
timestamp | 事件发生时间(ISO 8601) |
data | 事件发生时完整的任务对象(上面是节选),与当时 GET /v1/tasks/{id} 的返回一致,通常无需再查询 |
webhook-id | 事件 ID。重试时不变,请求体也不变,用它去重 |
webhook-timestamp | 本次发送的时间(Unix 秒) |
webhook-signature | 见验证签名 |
送达与重试
- 15 秒内返回
2xx即视为送达。其他情况都算未送达:其他状态码(包括3xx,不跟随重定向)、超时、连接失败。 - 未送达时重试 3 次,依次在 1 分钟、5 分钟、30 分钟后(最多共发送 4 次),之后放弃。任务结果仍可通过
GET /v1/tasks/{id}查询。 - 同一个事件可能收到多次,例如你返回的
2xx没有及时到达。任务的终态不会再改变。
处理事件
- (可选)验证签名。
- 立即返回
2xx,之后再处理,例如放进后台任务。 - 已处理过的
webhook-id直接跳过。 - 收到
task.succeeded时从data.outputs下载结果;收到task.failed时读取data.error.code,冻结金额已退回余额。
兜底:创建任务超过 60 分钟仍未收到回调时(例如所有发送都失败了),调用一次 GET /v1/tasks/{id}。
回调地址要求
- 只能是
https,最长 2048 字符。 - 不能指向内网、回环或保留地址。创建任务时检查地址本身;每次发送前还会解析域名,解析结果中有任何一个不是公网地址,这次就不发送,按未送达处理。
验证签名(可选)
每个回调都带签名。不验证时,知道回调地址的人可以伪造回调,所以请勿公开回调地址;收到回调后要做重要操作(例如发货、扣费)时,建议验证签名。
签名密钥
- 以
whsec_开头,在控制台 → Webhooks 查看和重置。 - 每个用户一个,所有任务的回调都用它签名。
- 重置后旧密钥继续有效 24 小时,期间每个回调同时带新旧两个签名。先部署新密钥,再停用旧的。24 小时内再次重置,更早的那个旧密钥立即失效。
签名算法
- 签名内容:
{webhook-id}.{webhook-timestamp}.{原始请求体},即两个请求头和原始请求体用英文句点连接。 - 密钥:
whsec_之后部分的 base64 解码值。 - 算法:对签名内容做 HMAC-SHA256,结果 base64 编码。
- 请求头:
webhook-signature可能包含多个以空格分隔的签名(密钥轮换期间如v1,xxx v1,yyy),任意一个v1签名匹配即通过。请使用常量时间比较。 - 防重放:拒绝
webhook-timestamp与当前时间相差超过 5 分钟(300 秒)的请求。
务必用收到的原始请求体验证。不要解析 JSON 后再序列化,字节可能不同,签名就对不上了。
只用标准库(Python 3.9+)。
import base64, hashlib, hmac, time
def verify_webhook(secret: str, headers: dict, body: bytes) -> bool:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for item in headers["webhook-signature"].split(" "):
version, _, signature = item.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return FalseFlask 示例(request.headers 不区分大小写,request.get_data() 是原始请求体):
import os
from flask import Flask, request
from verify_webhook import verify_webhook
app = Flask(__name__)
SECRET = os.environ["NANOAPI_WEBHOOK_SECRET"]
@app.post("/hooks/nanoapi")
def nanoapi_webhook():
if not verify_webhook(SECRET, request.headers, request.get_data()):
return "", 401
event = request.get_json()
# 放进后台队列异步处理,用 webhook-id 去重
print(request.headers["webhook-id"], event["type"], event["data"]["status"])
return "", 204