Webhooks
Recibe las tareas terminadas en tu propia URL, verifica la firma y gestiona reintentos y duplicados.
Con un webhook, PopNow te avisa cuando una tarea termina y no tienes que preguntar una y otra vez. Hay dos tipos:
| Callback de tarea | Endpoint de cuenta | |
|---|---|---|
| Configuración | callBackUrl en cada createTask | Una vez, en la consola de la API |
| Se envía para | Esa tarea | Cada tarea de la cuenta que encaje con los eventos que elijas |
| Firma | X-Webhook-Signature (abajo) | Cabeceras de Standard Webhooks (ver abajo) |
A la mayoría de las integraciones les basta con el callback de tarea.
1. Consigue tu secreto de firma
Cada entrega se firma con el secreto de firma de webhooks de tu cuenta. Abre la página Webhooks de la consola de la API, genera (o rota) el secreto y guárdalo en tu servidor como POPNOW_WEBHOOK_SECRET. Solo se muestra una vez. La rotación tiene efecto de inmediato, incluso para entregas que se están reintentando, así que actualiza tus receptores justo después.
2. Pasa 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"
}La URL debe ser HTTPS pública en el puerto 443 (o sin puerto), sin usuario ni contraseña, y resolver solo a direcciones públicas: se comprueba al crear la tarea y otra vez al conectar. http://localhost no puede funcionar; mientras desarrollas, usa un túnel como Cloudflare Tunnel o ngrok, o consulta la tarea. Una URL no válida devuelve 400 con msg: "Invalid callback URL".
3. Qué llega
Cuando la tarea alcanza un estado final, PopNow envía 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: 2El cuerpo es exactamente lo que devolvería recordInfo para esa tarea:
{
"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…"
}codees200incluso si la tarea falló: describe la entrega, no la tarea. Leedata.state.request_ides el ID de entrega. Se mantiene igual entre reintentos, así que úsalo para ignorar duplicados.- Los enlaces firmados de
outputcaducan a los 20 minutos más o menos. Guarda eltaskId, no el enlace.
4. Verifica la firma
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId es data.taskId del cuerpo, timestamp es la cabecera X-Webhook-Timestamp y la última parte es el SHA-256 del cuerpo en bruto, en hexadecimal en minúsculas. Calcula el hash de los bytes que recibiste, no de un objeto reserializado: el orden de las claves y los espacios cambiarían el resultado.
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 es 2 en toda integración nueva. La versión 1 es un formato antiguo que se mantiene para receptores existentes; si alguna vez la ves, el ID de tarea está en task_id, en el nivel superior.
Responde rápido
Cualquier 2xx en menos de 15 segundos cuenta como recibido. Todo lo demás —otro estado, un timeout, una conexión rechazada— se reintenta. Confirma primero y haz el trabajo lento (descargar, transcodificar, avisar a los usuarios) en un proceso en segundo plano.
Reintentos y duplicados
Una entrega fallida se reintenta 7 veces más, tras 10 s, 30 s, 2 min, 10 min, 30 min, 2 h y 6 h. Cada reintento se vuelve a firmar con una marca de tiempo nueva, así que compara siempre la marca de tiempo con la hora actual, no con el primer intento. Tras el último reintento, la entrega se marca como fallida en la consola de la API, donde puedes volver a enviarla a mano.
Por eso la misma tarea puede llegar más de una vez. Haz que tu manejador sea idempotente: guarda request_id con un índice único y, si lo vuelves a ver, responde 200 y no hagas nada.
Si tu servidor estuvo caído más tiempo que la ventana de reintentos, lista las tareas recientes con GET /api/v1/jobs y lee con recordInfo las que te perdiste.
Endpoints de cuenta
Un endpoint de cuenta es una URL que registras una vez en la consola de la API junto con los eventos que quiere recibir. Recibe eventos de todas las tareas de la cuenta, así que ya no tienes que pasar callBackUrl.
| Evento | Se envía cuando |
|---|---|
task.completed | Ocurre cualquiera de los cuatro siguientes. Suscríbete a este salvo que necesites distinguirlos |
task.succeeded | Una tarea terminó bien |
task.failed | Una tarea falló; se liberó la retención |
task.expired | Una tarea superó su plazo; se liberó la retención |
task.canceled | Solo se conserva para registros antiguos |
Las entregas siguen el formato Standard Webhooks, así que las librerías existentes funcionan tal cual:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Dos diferencias con el callback de tarea: se firma el propio cuerpo (sin paso de SHA-256), y la clave HMAC son los bytes que obtienes al decodificar en Base64 la parte del secreto que va después de whsec_, no la cadena en sí. Durante una rotación del secreto, la cabecera puede traer dos firmas separadas por un espacio; acepta la solicitud si coincide cualquiera de ellas. webhook-id se mantiene igual entre reintentos: úsalo para ignorar duplicados. El data.object del evento es el registro de la tarea en el momento en que se creó el evento.

