Webhooks
Fertige Tasks an deiner eigenen URL empfangen, die Signatur prüfen und mit Wiederholungen und Duplikaten umgehen.
Mit einem Webhook sagt dir PopNow Bescheid, wenn ein Task fertig ist – du musst nicht ständig nachfragen. Es gibt zwei Arten:
| Task-Callback | Konto-Endpunkt | |
|---|---|---|
| Einrichtung | callBackUrl bei jedem createTask | Einmal in der API-Konsole |
| Gesendet für | Diesen einen Task | Jeden Task des Kontos, der zu den gewählten Ereignissen passt |
| Signatur | X-Webhook-Signature (siehe unten) | Standard-Webhooks-Header (siehe unten) |
Den meisten Integrationen genügt der Task-Callback.
1. Signatur-Secret holen
Jede Zustellung wird mit dem Webhook-Signatur-Secret deines Kontos signiert. Öffne die Seite Webhooks in der API-Konsole, erzeuge (oder rotiere) das Secret und leg es auf deinem Server als POPNOW_WEBHOOK_SECRET ab. Es wird nur einmal angezeigt. Eine Rotation wirkt sofort, auch für Zustellungen, die gerade wiederholt werden – aktualisiere deine Empfänger also direkt danach.
2. callBackUrl übergeben
{
"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"
}Die URL muss öffentliches HTTPS auf Port 443 (oder ohne Port) sein, ohne Benutzername oder Passwort, und darf nur zu öffentlichen Adressen auflösen – geprüft beim Erstellen des Tasks und noch einmal beim Verbinden. http://localhost kann nicht funktionieren; nimm beim Entwickeln einen Tunnel wie Cloudflare Tunnel oder ngrok oder polle. Eine ungültige URL liefert 400 mit msg: "Invalid callback URL".
3. Was ankommt
Erreicht der Task einen Endzustand, schickt PopNow einen 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: 2Der Body ist genau das, was recordInfo für den Task liefern würde:
{
"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…"
}codeist auch bei einem gescheiterten Task200: Es beschreibt die Zustellung, nicht den Task. Liesdata.state.request_idist die Zustellungs-ID. Sie bleibt bei Wiederholungen gleich – nutz sie, um Duplikate zu ignorieren.- Die signierten Links in
outputlaufen nach etwa 20 Minuten ab. Speichere dietaskId, nicht den Link.
4. Signatur prüfen
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId ist data.taskId aus dem Body, timestamp der Header X-Webhook-Timestamp, und der letzte Teil ist der SHA-256 des rohen Request-Bodys in kleingeschriebenem Hex. Hashe die Bytes, die du bekommen hast, nicht ein neu serialisiertes Objekt: Schlüsselreihenfolge und Leerraum würden das Ergebnis ändern.
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 "", 200X-Webhook-Payload-Version ist bei jeder neuen Integration 2. Version 1 ist ein älteres Format für bestehende Empfänger; falls du es je siehst, steht die Task-ID im obersten Feld task_id.
Schnell antworten
Jede 2xx-Antwort innerhalb von 15 Sekunden gilt als empfangen. Alles andere – ein anderer Status, ein Timeout, eine abgelehnte Verbindung – wird wiederholt. Bestätige zuerst und erledige die langsame Arbeit (Herunterladen, Umwandeln, Nutzer benachrichtigen) in einem Hintergrundjob.
Wiederholungen und Duplikate
Eine fehlgeschlagene Zustellung wird noch 7-mal wiederholt, nach 10 s, 30 s, 2 min, 10 min, 30 min, 2 h und 6 h. Jede Wiederholung wird mit frischem Zeitstempel neu signiert – vergleiche den Zeitstempel also immer mit der aktuellen Zeit, nicht mit dem ersten Versuch. Nach der letzten Wiederholung wird die Zustellung in der API-Konsole als fehlgeschlagen markiert, wo du sie von Hand erneut senden kannst.
Derselbe Task kann also mehr als einmal ankommen. Mach deinen Handler idempotent: Speichere request_id mit eindeutigem Index, und wenn du sie wiedersiehst, antworte 200 und tu nichts.
War dein Server länger als das Wiederholungsfenster weg, liste die letzten Tasks mit GET /api/v1/jobs und lies die verpassten mit recordInfo.
Konto-Endpunkte
Ein Konto-Endpunkt ist eine URL, die du einmal in der API-Konsole zusammen mit den gewünschten Ereignissen registrierst. Er erhält Ereignisse zu jedem Task des Kontos, du musst callBackUrl also nie wieder mitschicken.
| Ereignis | Gesendet, wenn |
|---|---|
task.completed | Eins der vier folgenden eintritt. Abonniere das, sofern du sie nicht unterscheiden musst |
task.succeeded | Ein Task erfolgreich fertig ist |
task.failed | Ein Task gescheitert ist; die Reservierung wurde freigegeben |
task.expired | Ein Task seine Frist überschritten hat; die Reservierung wurde freigegeben |
task.canceled | Nur für alte Einträge erhalten |
Zustellungen folgen dem Format Standard Webhooks, sodass fertige Bibliotheken funktionieren:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Zwei Unterschiede zum Task-Callback: Signiert wird der Body selbst (kein SHA-256-Schritt), und der HMAC-Schlüssel sind die Bytes, die du durch Base64-Dekodieren des Teils nach whsec_ erhältst – nicht der String. Während einer Secret-Rotation kann der Header zwei durch Leerzeichen getrennte Signaturen enthalten; akzeptiere die Anfrage, wenn eine davon passt. webhook-id bleibt bei Wiederholungen gleich: nutz sie, um Duplikate zu ignorieren. data.object im Ereignis ist der Task-Eintrag zum Zeitpunkt, als das Ereignis entstand.

