PopNowDocs de la API
Construir con la API
Contenido principal

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 tareaEndpoint de cuenta
ConfiguracióncallBackUrl en cada createTaskUna vez, en la consola de la API
Se envía paraEsa tareaCada tarea de la cuenta que encaje con los eventos que elijas
FirmaX-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: 2

El 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…"
}
  • code es 200 incluso si la tarea falló: describe la entrega, no la tarea. Lee data.state.
  • request_id es el ID de entrega. Se mantiene igual entre reintentos, así que úsalo para ignorar duplicados.
  • Los enlaces firmados de output caducan a los 20 minutos más o menos. Guarda el taskId, 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.

Node.js (Express)
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
});
Python (Flask)
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 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.

EventoSe envía cuando
task.completedOcurre cualquiera de los cuatro siguientes. Suscríbete a este salvo que necesites distinguirlos
task.succeededUna tarea terminó bien
task.failedUna tarea falló; se liberó la retención
task.expiredUna tarea superó su plazo; se liberó la retención
task.canceledSolo 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.

En esta página