PopNowAPI 文件
用 API 開發
主要內容

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,小寫十六進位。請對收到的位元組做雜湊,不要先解析再重新序列化:鍵的順序和空白一變,結果就不同了。

Node.js (Express)
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
});
Python (Flask)
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 是事件產生當下的任務紀錄。

本頁內容