Вебхуки
Как получать завершённые задачи на свой URL, проверять подпись и обрабатывать повторы и дубликаты.
Вебхук позволяет PopNow самому сообщить вам, что задача готова, — не нужно спрашивать снова и снова. Есть два вида:
| Колбэк задачи | Эндпоинт аккаунта | |
|---|---|---|
| Настройка | callBackUrl в каждом createTask | Один раз в API-консоли |
| Для чего отправляется | Для этой одной задачи | Для каждой задачи аккаунта, подходящей под выбранные события |
| Подпись | X-Webhook-Signature (ниже) | Заголовки Standard Webhooks (ниже) |
Большинству интеграций хватает колбэка задачи.
1. Получите секрет подписи
Каждая доставка подписывается секретом подписи вебхуков вашего аккаунта. Откройте страницу Webhooks в API-консоли, создайте (или обновите) секрет и сохраните его на сервере как 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 (или без порта), без имени пользователя и пароля, и разрешаться только в публичные адреса — это проверяется при создании задачи и ещё раз при подключении. http://localhost работать не может; при разработке используйте туннель вроде Cloudflare Tunnel или ngrok либо опрос. Неверный URL даёт 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 сырого тела запроса в нижнем регистре hex. Хешируйте полученные байты, а не заново сериализованный объект: порядок ключей и пробелы изменят результат.
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.
Отвечайте быстро
Любой ответ 2xx в течение 15 секунд считается доставкой. Всё остальное — другой статус, таймаут, отказ в соединении — повторяется. Сначала подтвердите получение, а медленную работу (скачивание, перекодирование, уведомления пользователей) делайте в фоновой задаче.
Повторы и дубликаты
Неудачная доставка повторяется ещё 7 раз — через 10 с, 30 с, 2 мин, 10 мин, 30 мин, 2 ч и 6 ч. Каждый повтор подписывается заново со свежей отметкой времени, поэтому всегда сравнивайте её с текущим временем, а не с первой попыткой. После последнего повтора доставка помечается в API-консоли как неудачная, и её можно отправить вручную.
Значит, одна и та же задача может прийти не один раз. Сделайте обработчик идемпотентным: храните request_id с уникальным индексом и, увидев его снова, отвечайте 200 и ничего не делайте.
Если ваш сервер лежал дольше, чем длится окно повторов, получите последние задачи через GET /api/v1/jobs и дочитайте пропущенные через recordInfo.
Эндпоинты аккаунта
Эндпоинт аккаунта — это URL, который один раз регистрируется в 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 — это байты, полученные декодированием из Base64 части секрета после whsec_, а не сама строка. Во время смены секрета в заголовке может быть две подписи через пробел; принимайте запрос, если совпала любая. webhook-id между повторами не меняется — по нему отсекайте дубликаты. data.object в событии — это запись задачи на момент создания события.

