PopNowAPI ドキュメント
API で作る
本文

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 進数)です。受け取ったバイト列をハッシュしてください。いったん解析して再シリアライズすると、キーの順序や空白が変わって結果が一致しません。

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 を返せば受信済みとみなします。それ以外(別のステータス、タイムアウト、接続拒否)は再送の対象です。先に受信を返し、時間のかかる処理(ダウンロード、変換、ユーザーへの通知)はバックグラウンドで行ってください。

再送と重複

配信に失敗すると、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 は、イベントが作られた時点のタスク記録です。

このページの内容