Webhook 回调
通过开放 API 提交的任务结束时,秒做向你的服务器发送一次带签名的 HTTP 回调。签名遵循 Standard Webhooks 规范,常见语言都能用标准库验签。
配置回调地址
- 在开放平台的「Webhook」页设置账号默认回调地址,并查看签名密钥(
whsec_开头)。 - 提交任务时也可以用
webhook_url为单个任务指定地址,优先级高于默认地址;两者都没有时不回调。 - 回调地址必须是 HTTPS,不能是内网地址。
- 只有通过开放 API(
POST /v1/tasks)提交的任务会回调;网页创作和开放平台在线测试的任务不回调。 - 在开放平台点「发送测试」可以向默认地址发一条
webhook.test事件,用来验证地址和验签代码。
事件类型
| 事件 | 触发时机 |
|---|---|
task.succeeded | 任务成功,结果文件已可下载 |
task.failed | 任务失败,冻结点数已退回 |
task.canceled | 任务被取消,冻结点数已退回 |
webhook.test | 在开放平台手动发送的测试事件,data 不是任务对象 |
每个任务只回调一次(进入终态时)。
回调请求
秒做以 POST 发送 JSON,请求体包含 type(事件类型)、timestamp(事件时间)和 data(与查询任务返回相同的任务对象)。
回调请求示例
POST /miaozuo/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Miaozuo-Webhook/1.0
webhook-id: msg_2Lp8sV3nQ6wE1rTyK9uX4bZm
webhook-timestamp: 1791537812
webhook-signature: v1,I5I4xkt2V/ecur0xuqXD8afNtIl6xjzXpfyApwg41fA=
{"type":"task.succeeded","timestamp":"2026-10-09T17:03:32+08:00","data":{ …任务对象… }}| 请求头 | 说明 |
|---|---|
webhook-id | 本条消息的唯一编号(msg_ 开头)。自动重试和手动重发时保持不变,可用于去重 |
webhook-timestamp | 本次发送时间,Unix 时间戳(秒) |
webhook-signature | 签名列表,形如 v1,<Base64 签名>;轮换密钥期间有两个,用空格分隔 |
User-Agent | Miaozuo-Webhook/1.0 |
测试事件的请求体
{
"type": "webhook.test",
"timestamp": "2026-10-09T17:30:00+08:00",
"data": {
"message": "这是一条来自秒做的测试回调,用于验证地址与签名",
"account_webhook": true
}
}验证签名
- 取
whsec_之后的部分做 Base64 解码,得到签名密钥字节。 - 拼接待签内容:
{webhook-id}.{webhook-timestamp}.{原始请求体}。 - 用 HMAC-SHA256 计算签名并做 Base64 编码。
- 与
webhook-signature中每个v1,后的值做恒定时间比较,任一相同即通过。 - 检查
webhook-timestamp与当前时间相差不超过 5 分钟,防止重放。
必须使用原始请求体
验签要用收到的原始字节。先解析 JSON 再重新序列化,字段顺序、空白或转义可能变化,导致验签失败。
import base64
import hashlib
import hmac
import os
import time
# 开放平台 → Webhook 中查看,形如 whsec_xxx
WEBHOOK_SECRET = os.environ["MIAOZUO_WEBHOOK_SECRET"]
def verify_webhook(headers: dict, body: bytes, tolerance: int = 300) -> bool:
"""headers 的键请统一转成小写;body 必须是原始请求体字节(如 Flask 的 request.get_data())。"""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > tolerance:
return False # 超出 5 分钟,可能是重放
secret = base64.b64decode(WEBHOOK_SECRET.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = base64.b64encode(hmac.new(secret, signed, hashlib.sha256).digest()).decode()
# 轮换密钥期间可能同时带多个签名,用空格分隔,任一匹配即可
for item in signatures.split(" "):
version, _, signature = item.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return False可以用下面这组数据核对你的实现(请求体为下方「成功事件的完整请求体」,不含末尾换行):
验签核对数据
签名密钥:whsec_bWlhb3p1by1kZW1vLXdlYmhvb2sta2V5
webhook-id:msg_2Lp8sV3nQ6wE1rTyK9uX4bZm
webhook-timestamp:1791537812
请求体:下方「成功事件的完整请求体」(不含末尾换行)
webhook-signature:v1,I5I4xkt2V/ecur0xuqXD8afNtIl6xjzXpfyApwg41fA=成功事件的完整请求体
{"type":"task.succeeded","timestamp":"2026-10-09T17:03:32+08:00","data":{"id":"T261009170328CGM0GT","object":"task","model":"qwen-image-3.0","status":"succeeded","created_at":"2026-10-09T17:03:28+08:00","started_at":"2026-10-09T17:03:29+08:00","finished_at":"2026-10-09T17:03:32+08:00","input":{"prompt":"一只戴宇航员头盔的橘猫,蓝色星空背景","ratio":"1:1","resolution":"1k","count":1,"smart_prompt":true},"points":{"frozen":"33.000","charged":"33.000","refunded":"0.000"},"output":{"files":[{"url":"https://api.miaozuo.cn/ai/202610/104a3f61ba496027e7aebd892ec2fc86.png","type":"image","mime_type":"image/png","size":66589,"width":1024,"height":1024,"duration":null,"cover_url":null,"expires_at":"2026-11-08T17:03:32+08:00"}]},"error":null,"metadata":{"order_id":"1001"}}}轮换签名密钥
在开放平台轮换密钥后,24 小时内新旧两个密钥同时签名(webhook-signature 里有两个值),这期间把服务器上的密钥换成新的即可,不会漏验。
响应与重试
- 收到回调后请在 10 秒内返回任意 2xx 状态码,耗时处理请放到后台异步执行。
- 返回非 2xx(包括 3xx,秒做不跟随重定向)、超时或连接失败都视为投递失败,按以下间隔重试,最多 5 次(连首次共 6 次):
- 第 1 次重试上次失败 1 分钟后
- 第 2 次重试上次失败 5 分钟后
- 第 3 次重试上次失败 30 分钟后
- 第 4 次重试上次失败 2 小时后
- 第 5 次重试上次失败 6 小时后
- 每次投递都有记录,可在开放平台查看状态码与响应摘要,并手动重发(同一个
webhook-id,立即发送并返回结果,不再自动重试)。 - 同一事件可能送达多次,请按
webhook-id去重,保证处理幂等。 - 回调只作为「提醒」。涉及发货、入账等关键操作,建议收到回调后再查询任务确认一次。