Webhook
Ricevi le attività completate sul tuo URL, verifica la firma e gestisci nuovi tentativi e duplicati.
Con un webhook è PopNow ad avvisarti quando un’attività è finita, senza che tu debba chiedere di continuo. Ce ne sono di due tipi:
| Callback dell’attività | Endpoint dell’account | |
|---|---|---|
| Configurazione | callBackUrl in ogni createTask | Una volta sola, nella console API |
| Inviato per | Quella singola attività | Ogni attività dell’account che corrisponde agli eventi scelti |
| Firma | X-Webhook-Signature (sotto) | Header Standard Webhooks (vedi sotto) |
Alla maggior parte delle integrazioni basta il callback dell’attività.
1. Ottieni il segreto di firma
Ogni consegna è firmata con il segreto di firma dei webhook del tuo account. Apri la pagina Webhooks della console API, genera (o ruota) il segreto e salvalo sul server come POPNOW_WEBHOOK_SECRET. Viene mostrato una volta sola. La rotazione ha effetto subito, anche per le consegne ancora in fase di nuovo tentativo, quindi aggiorna i ricevitori appena dopo.
2. Passa 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"
}L’URL deve essere HTTPS pubblico sulla porta 443 (o senza porta), senza nome utente né password, e risolversi solo in indirizzi pubblici: viene controllato alla creazione dell’attività e di nuovo alla connessione. http://localhost non può funzionare; in sviluppo usa un tunnel come Cloudflare Tunnel o ngrok, oppure fai polling. Un URL non valido restituisce 400 con msg: "Invalid callback URL".
3. Cosa arriva
Quando l’attività raggiunge uno stato finale, PopNow invia un 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: 2Il corpo è esattamente ciò che recordInfo restituirebbe per quell’attività:
{
"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è200anche per un’attività fallita: descrive la consegna, non l’attività. Leggidata.state.request_idè l’ID della consegna. Non cambia tra un tentativo e l’altro, quindi usalo per ignorare i duplicati.- I link firmati in
outputscadono dopo circa 20 minuti. Salva iltaskId, non il link.
4. Verifica la firma
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId è data.taskId del corpo, timestamp è l’header X-Webhook-Timestamp e l’ultima parte è lo SHA-256 del corpo grezzo della richiesta, in esadecimale minuscolo. Calcola l’hash dei byte ricevuti, non di un oggetto riserializzato: ordine delle chiavi e spazi cambierebbero il risultato.
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 è 2 per ogni nuova integrazione. La versione 1 è un formato più vecchio mantenuto per i ricevitori esistenti; se mai la vedi, l’ID dell’attività è nel campo task_id al primo livello.
Rispondi in fretta
Qualsiasi 2xx entro 15 secondi vale come ricevuto. Tutto il resto — un altro stato, un timeout, una connessione rifiutata — viene ritentato. Conferma subito e fai il lavoro lento (download, transcodifica, notifiche agli utenti) in un job in background.
Nuovi tentativi e duplicati
Una consegna fallita viene ritentata altre 7 volte, dopo 10 s, 30 s, 2 min, 10 min, 30 min, 2 h e 6 h. Ogni tentativo viene firmato di nuovo con un timestamp nuovo, quindi confronta sempre il timestamp con l’ora attuale, non con il primo tentativo. Dopo l’ultimo, la consegna viene segnata come fallita nella console API, da dove puoi reinviarla a mano.
La stessa attività può quindi arrivare più di una volta. Rendi il tuo handler idempotente: salva request_id con un indice univoco e, se lo rivedi, rispondi 200 senza fare nulla.
Se il tuo server è rimasto giù più a lungo della finestra dei tentativi, elenca le attività recenti con GET /api/v1/jobs e leggi quelle perse con recordInfo.
Endpoint dell’account
Un endpoint dell’account è un URL registrato una volta nella console API insieme agli eventi che vuole ricevere. Riceve gli eventi di ogni attività dell’account, così non devi più passare callBackUrl.
| Evento | Inviato quando |
|---|---|
task.completed | Si verifica uno qualsiasi dei quattro sotto. Iscriviti a questo, a meno che tu non debba distinguerli |
task.succeeded | Un’attività è riuscita |
task.failed | Un’attività è fallita; la trattenuta è stata rilasciata |
task.expired | Un’attività ha superato la scadenza; la trattenuta è stata rilasciata |
task.canceled | Conservato solo per i vecchi record |
Le consegne seguono il formato Standard Webhooks, quindi le librerie già pronte funzionano:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Due differenze rispetto al callback dell’attività: si firma il corpo stesso (senza il passaggio SHA-256) e la chiave HMAC sono i byte ottenuti decodificando in Base64 la parte del segreto dopo whsec_, non la stringa. Durante una rotazione del segreto l’header può contenere due firme separate da uno spazio; accetta la richiesta se una delle due corrisponde. webhook-id resta uguale tra i tentativi: usalo per ignorare i duplicati. Il data.object dell’evento è il record dell’attività nel momento in cui l’evento è stato creato.

