Webhook
完了したタスクを自分の URL で受け取り、署名を検証し、再送と重複に対応する方法。
Webhook を使うと、タスクが終わったときに PopNow から知らせが届くので、何度も問い合わせる必要がありません。種類は 2 つあります。
| タスクのコールバック | アカウントのエンドポイント | |
|---|---|---|
| 設定方法 | createTask ごとに callBackUrl を指定 | API コンソールで一度登録 |
| 届くもの | そのタスク 1 件 | 選んだイベントに一致する、アカウントのすべてのタスク |
| 署名 | 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"
}URL は公開された HTTPS で、ポートは 443(または省略)、ユーザー名やパスワードを含まず、名前解決の結果が公開アドレスだけである必要があります。タスク作成時と接続時の 2 回確認します。http://localhost には届かないので、開発中は Cloudflare Tunnel や ngrok などのトンネルを使うか、ポーリングしてください。不正な URL は 400(msg: "Invalid callback URL")になります。
3. 届く内容
タスクが終了状態になると、PopNow は POST を 1 回送ります。
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(小文字の 16 進数)です。受け取ったバイト列をハッシュしてください。いったん解析して再シリアライズすると、キーの順序や空白が変わって結果が一致しません。
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 を返せば受信済みとみなします。それ以外(別のステータス、タイムアウト、接続拒否)は再送の対象です。先に受信を返し、時間のかかる処理(ダウンロード、変換、ユーザーへの通知)はバックグラウンドで行ってください。
再送と重複
配信に失敗すると、10 秒、30 秒、2 分、10 分、30 分、2 時間、6 時間の間隔で、さらに 7 回再送します。再送のたびに新しいタイムスタンプで署名し直すので、タイムスタンプは初回ではなく常に現在時刻と比べてください。最後の再送でも失敗すると、その配信は API コンソールで失敗として表示され、そこから手動で再送できます。
つまり同じタスクが複数回届くことがあります。処理は冪等にしてください。request_id にユニークインデックスを張り、同じものが来たら何もせずに 200 を返します。
サーバーが再送期間より長く止まっていた場合は、GET /api/v1/jobs で最近のタスクを一覧し、取りこぼしたものを recordInfo で読みます。
アカウントのエンドポイント
アカウントのエンドポイントは、受け取りたいイベントと一緒に API コンソールで一度だけ登録する URL です。アカウントのすべてのタスクのイベントが届くので、callBackUrl を毎回渡す必要がなくなります。
| イベント | 送られるとき |
|---|---|
task.completed | 下の 4 つのいずれか。区別が必要でなければこれを購読してください |
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>" ) )タスクのコールバックとの違いは 2 つです。署名するのは本文そのもの(SHA-256 の段階はありません)で、HMAC の鍵はシークレットの whsec_ 以降を Base64 デコードしたバイト列であり、文字列そのものではありません。シークレットのローテーション中はヘッダーにスペース区切りで署名が 2 つ入ることがあり、どちらかが一致すれば受け入れます。webhook-id は再送でも変わらないので、重複の判定に使ってください。イベントの data.object は、イベントが作られた時点のタスク記録です。

