Webhooki
Odbieraj gotowe zadania na własny adres URL, weryfikuj podpis i obsługuj ponowienia oraz duplikaty.
Dzięki webhookowi PopNow samo da ci znać, że zadanie się skończyło — nie musisz pytać w kółko. Są dwa rodzaje:
| Callback zadania | Endpoint konta | |
|---|---|---|
| Konfiguracja | callBackUrl przy każdym createTask | Raz, w konsoli API |
| Wysyłany dla | Tego jednego zadania | Każdego zadania na koncie pasującego do wybranych zdarzeń |
| Podpis | X-Webhook-Signature (niżej) | Nagłówki Standard Webhooks (niżej) |
Większości integracji wystarcza callback zadania.
1. Zdobądź sekret podpisu
Każde doręczenie jest podpisane sekretem podpisu webhooków twojego konta. Otwórz stronę Webhooks w konsoli API, wygeneruj (albo wymień) sekret i zapisz go na serwerze jako POPNOW_WEBHOOK_SECRET. Wyświetla się tylko raz. Wymiana działa od razu, także dla doręczeń, które są jeszcze ponawiane, więc zaraz potem zaktualizuj odbiorców.
2. Przekaż 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"
}Adres musi być publicznym HTTPS na porcie 443 (albo bez portu), bez nazwy użytkownika i hasła, i rozwiązywać się wyłącznie na publiczne adresy — sprawdzamy to przy tworzeniu zadania i ponownie przy połączeniu. http://localhost nie zadziała; podczas pracy lokalnej użyj tunelu w rodzaju Cloudflare Tunnel lub ngrok albo odpytuj. Nieprawidłowy adres zwraca 400 z msg: "Invalid callback URL".
3. Co przychodzi
Gdy zadanie osiągnie stan końcowy, PopNow wysyła jeden 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: 2Treść jest dokładnie taka, jaką recordInfo zwróciłoby dla tego zadania:
{
"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…"
}codema wartość200nawet dla nieudanego zadania: opisuje doręczenie, a nie zadanie. Czytajdata.state.request_idto ID doręczenia. Nie zmienia się między ponowieniami, więc służy do pomijania duplikatów.- Podpisane linki w
outputwygasają po około 20 minutach. ZapisujtaskId, nie link.
4. Zweryfikuj podpis
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId to data.taskId z treści, timestamp to nagłówek X-Webhook-Timestamp, a ostatnia część to SHA-256 surowej treści zapytania, zapisany małymi literami w hex. Hashuj bajty, które dostałeś, a nie ponownie zserializowany obiekt: kolejność kluczy i białe znaki zmieniłyby wynik.
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 "", 200W każdej nowej integracji X-Webhook-Payload-Version ma wartość 2. Wersja 1 to starszy format zachowany dla istniejących odbiorców; jeśli kiedyś się pojawi, ID zadania jest w polu task_id na najwyższym poziomie.
Odpowiadaj szybko
Każda odpowiedź 2xx w ciągu 15 sekund oznacza odbiór. Wszystko inne — inny status, przekroczony czas, odrzucone połączenie — jest ponawiane. Najpierw potwierdź, a powolną pracę (pobieranie, transkodowanie, powiadamianie użytkowników) wykonuj w zadaniu w tle.
Ponowienia i duplikaty
Nieudane doręczenie jest ponawiane jeszcze 7 razy: po 10 s, 30 s, 2 min, 10 min, 30 min, 2 h i 6 h. Każde ponowienie jest podpisywane od nowa ze świeżym znacznikiem czasu, więc zawsze porównuj go z bieżącą godziną, a nie z pierwszą próbą. Po ostatniej próbie doręczenie jest oznaczane w konsoli API jako nieudane i tam możesz wysłać je ponownie ręcznie.
To samo zadanie może więc dotrzeć więcej niż raz. Zrób obsługę idempotentną: zapisuj request_id z unikalnym indeksem, a gdy zobaczysz go znowu, odpowiedz 200 i nic nie rób.
Jeśli twój serwer leżał dłużej niż okno ponowień, wylistuj ostatnie zadania przez GET /api/v1/jobs i doczytaj przegapione przez recordInfo.
Endpointy konta
Endpoint konta to adres URL rejestrowany raz w konsoli API razem ze zdarzeniami, które ma odbierać. Dostaje zdarzenia o wszystkich zadaniach na koncie, więc nie musisz już przekazywać callBackUrl.
| Zdarzenie | Kiedy jest wysyłane |
|---|---|
task.completed | Zaszło którekolwiek z czterech poniżej. Subskrybuj to, chyba że musisz je rozróżniać |
task.succeeded | Zadanie zakończyło się sukcesem |
task.failed | Zadanie się nie powiodło; blokada została zwolniona |
task.expired | Zadanie przekroczyło termin; blokada została zwolniona |
task.canceled | Zachowane tylko dla starych rekordów |
Doręczenia są zgodne z formatem Standard Webhooks, więc gotowe biblioteki działają bez zmian:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Dwie różnice względem callbacku zadania: podpisywana jest sama treść (bez kroku SHA-256), a kluczem HMAC są bajty uzyskane przez zdekodowanie z Base64 części sekretu po whsec_ — nie sam ciąg znaków. W trakcie wymiany sekretu nagłówek może zawierać dwa podpisy oddzielone spacją; przyjmij zapytanie, jeśli pasuje którykolwiek. webhook-id nie zmienia się między ponowieniami: używaj go do pomijania duplikatów. data.object w zdarzeniu to rekord zadania z chwili utworzenia zdarzenia.

