Webhook
在你自己的網址接收完成的任務,驗證簽章,並處理重試與重複送達。
有了 Webhook,任務完成時 PopNow 會主動通知你,不必一再詢問。共有兩種:
| 任務回呼 | 帳戶端點 | |
|---|---|---|
| 如何設定 | 每次 createTask 帶上 callBackUrl | 在 API 控制台登記一次 |
| 傳送什麼 | 這一個任務 | 帳戶中所有符合你所選事件的任務 |
| 簽章 | X-Webhook-Signature(見下文) | Standard Webhooks 規範的標頭(見下文) |
大多數整合只需要任務回呼。
1. 取得簽章密鑰
每次送達都會以你帳戶的 Webhook 簽章密鑰簽名。開啟 API 控制台 的 Webhooks 頁,產生(或輪替)密鑰,存放在伺服器上,記為 POPNOW_WEBHOOK_SECRET。它只會顯示一次。輪替會立即生效,包括仍在重試中的送達,所以輪替後請馬上更新接收端。
2. 傳入 callBackUrl
{
"model": "alibaba/wan-3.0/image-to-video",
"input": { "image_url": "https://media.popnow.ai/movie/lighthouse.webp" },
"callBackUrl": "https://your-app.example.com/hooks/popnow"
}這個網址必須是公開的 HTTPS,連接埠為 443(或不指定),不能含使用者名稱或密碼,且解析結果只能是公開位址——建立任務時檢查一次,連線時還會再檢查一次。http://localhost 不可能收到;本機開發請使用 Cloudflare Tunnel 或 ngrok 之類的通道,或改用輪詢。網址不合法會回傳 400,msg 為 "Invalid callback URL"。
3. 收到的內容
任務進入終止狀態時,PopNow 會送出一次 POST:
POST /hooks/popnow HTTP/1.1
Content-Type: application/json
User-Agent: PopNow-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk3…=
X-Webhook-Payload-Version: 2請求內容與 recordInfo 對該任務的回傳完全相同:
{
"code": 200,
"msg": "success",
"data": {
"taskId": "job_01JH8Z4Q5W2N7R3K9M6P1C0VXD",
"model": "alibaba/wan-3.0/image-to-video",
"state": "succeeded",
"output": { "assets": [{ "url": "https://…", "mime": "video/mp4", "durationSeconds": 5 }] },
"credits": 34,
"cost": "0.34",
"settled": true
},
"request_id": "whd_01JH8Z…"
}- 即使任務失敗,
code仍是200:它描述的是這次送達,而不是任務。請看data.state。 request_id是送達 ID,重試時保持不變,可用來忽略重複送達。output中的簽名連結約 20 分鐘後過期。請儲存taskId,不要儲存連結。
4. 驗證簽章
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId 取自請求內容的 data.taskId,timestamp 是 X-Webhook-Timestamp 標頭,最後一段是原始請求內容的 SHA-256,小寫十六進位。請對收到的位元組做雜湊,不要先解析再重新序列化:鍵的順序和空白一變,結果就不同了。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.POPNOW_WEBHOOK_SECRET;
// express.raw keeps the exact bytes; express.json would throw them away.
app.post('/hooks/popnow', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
const ts = Number(req.headers['x-webhook-timestamp']);
const sig = String(req.headers['x-webhook-signature'] ?? '');
// Refuse anything older than five minutes, however valid its signature.
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end();
let event;
try { event = JSON.parse(req.body.toString('utf8')); } catch { return res.status(400).end(); }
const digest = crypto.createHash('sha256').update(req.body).digest('hex');
const expected = crypto.createHmac('sha256', SECRET)
.update(`${event.data?.taskId}.${ts}.${digest}`).digest('base64');
const ok = expected.length === sig.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
if (!ok) return res.status(401).end();
res.status(200).end(); // acknowledge first…
enqueue(event.request_id, event.data); // …then do the work
});import hashlib, hmac, base64, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["POPNOW_WEBHOOK_SECRET"].encode()
@app.post("/hooks/popnow")
def popnow_hook():
raw = request.get_data() # the exact bytes
ts = request.headers.get("X-Webhook-Timestamp", "")
sig = request.headers.get("X-Webhook-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
abort(401)
event = json.loads(raw)
digest = hashlib.sha256(raw).hexdigest()
message = f'{event["data"]["taskId"]}.{ts}.{digest}'.encode()
expected = base64.b64encode(hmac.new(SECRET, message, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(expected, sig):
abort(401)
enqueue(event["request_id"], event["data"]) # do the slow work elsewhere
return "", 200新整合的 X-Webhook-Payload-Version 一律是 2。版本 1 是為舊有接收端保留的舊格式;萬一收到,任務 ID 位於最上層的 task_id。
盡快回應
15 秒內回傳任何 2xx 就算收到。其他情況——別的狀態碼、逾時、連線被拒——都會重試。請先確認收到,再把耗時的工作(下載、轉檔、通知使用者)交給背景工作處理。
重試與重複
送達失敗後會再重試 7 次,間隔依序為 10 秒、30 秒、2 分鐘、10 分鐘、30 分鐘、2 小時與 6 小時。每次重試都會以新的時間戳記重新簽章,所以請一律拿時間戳記和目前時間比較,而不是和第一次送達比較。最後一次重試也失敗後,這次送達會在 API 控制台標示為失敗,你可以在那裡手動重送。
因此同一個任務可能不只送達一次。請讓處理流程具冪等性:為 request_id 建立唯一索引,再次遇到時直接回 200,不做任何事。
如果你的伺服器停機時間超過重試期間,請用 GET /api/v1/jobs 列出近期任務,再用 recordInfo 補讀漏掉的。
帳戶端點
帳戶端點是在 API 控制台登記一次的網址,並選好要接收的事件。它會收到帳戶中所有任務的事件,你就不必每次都傳 callBackUrl。
| 事件 | 何時送出 |
|---|---|
task.completed | 以下四種任一種。除非需要區分,訂閱這一個就好 |
task.succeeded | 任務成功完成 |
task.failed | 任務失敗;保留的點數已退回 |
task.expired | 任務超過截止時間;保留的點數已退回 |
task.canceled | 只為舊紀錄保留 |
送達遵循 Standard Webhooks 格式,現成的函式庫可以直接使用:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )與任務回呼有兩點不同:簽的是請求內容本身(沒有 SHA-256 這一步);HMAC 金鑰是密鑰字串中 whsec_ 之後那段經 Base64 解碼得到的位元組,而不是字串本身。輪替密鑰期間,標頭中可能有兩個以空格分隔的簽章,任一個相符就接受。webhook-id 在重試時保持不變,可用來忽略重複。事件中的 data.object 是事件產生當下的任務紀錄。

