Webhooks
Receba tarefas concluídas na sua própria URL, verifique a assinatura e lide com novas tentativas e duplicatas.
Com um webhook, a PopNow avisa quando uma tarefa termina e você não precisa ficar perguntando. Existem dois tipos:
| Callback de tarefa | Endpoint da conta | |
|---|---|---|
| Configuração | callBackUrl em cada createTask | Uma vez, no console da API |
| Enviado para | Aquela tarefa | Toda tarefa da conta que bate com os eventos escolhidos |
| Assinatura | X-Webhook-Signature (abaixo) | Cabeçalhos do Standard Webhooks (veja abaixo) |
A maioria das integrações só precisa do callback de tarefa.
1. Pegue seu segredo de assinatura
Toda entrega é assinada com o segredo de assinatura de webhooks da sua conta. Abra a página Webhooks do console da API, gere (ou rotacione) o segredo e guarde no servidor como POPNOW_WEBHOOK_SECRET. Ele aparece uma única vez. A rotação vale na hora, inclusive para entregas ainda em nova tentativa, então atualize seus receptores logo depois.
2. Passe o 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"
}A URL precisa ser HTTPS pública na porta 443 (ou sem porta), sem usuário nem senha, e resolver apenas para endereços públicos — isso é verificado ao criar a tarefa e de novo na conexão. http://localhost não tem como funcionar; durante o desenvolvimento, use um túnel como Cloudflare Tunnel ou ngrok, ou faça polling. Uma URL inválida retorna 400 com msg: "Invalid callback URL".
3. O que chega
Quando a tarefa chega a um estado final, a PopNow envia um 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: 2O corpo é exatamente o que o recordInfo devolveria para a tarefa:
{
"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é200mesmo para uma tarefa com falha: ele descreve a entrega, não a tarefa. Leiadata.state.request_idé o ID da entrega. Ele não muda entre tentativas, então use-o para ignorar duplicatas.- Os links assinados em
outputexpiram em uns 20 minutos. Guarde otaskId, não o link.
4. Verifique a assinatura
signature = base64( HMAC-SHA256( secret, "<taskId>.<timestamp>.<sha256_hex(raw_body)>" ) )taskId é o data.taskId do corpo, timestamp é o cabeçalho X-Webhook-Timestamp, e a última parte é o SHA-256 do corpo bruto da requisição, em hexadecimal minúsculo. Faça o hash dos bytes que você recebeu, não de um objeto reserializado: a ordem das chaves e os espaços mudariam o 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 é 2 em toda integração nova. A versão 1 é um formato antigo mantido para receptores existentes; se aparecer, o ID da tarefa fica em task_id, no nível mais alto.
Responda rápido
Qualquer 2xx em até 15 segundos conta como recebido. O resto — outro status, timeout, conexão recusada — vira nova tentativa. Confirme primeiro e faça o trabalho pesado (baixar, transcodificar, avisar usuários) num job em segundo plano.
Novas tentativas e duplicatas
Uma entrega que falha é tentada mais 7 vezes, depois de 10 s, 30 s, 2 min, 10 min, 30 min, 2 h e 6 h. Cada tentativa é assinada de novo com um timestamp novo, então compare sempre o timestamp com a hora atual, não com a primeira tentativa. Depois da última, a entrega aparece como falha no console da API, onde você pode reenviá-la manualmente.
Por isso a mesma tarefa pode chegar mais de uma vez. Deixe seu handler idempotente: guarde request_id com índice único e, ao vê-lo de novo, responda 200 sem fazer nada.
Se o seu servidor ficou fora do ar por mais tempo que a janela de tentativas, liste as tarefas recentes com GET /api/v1/jobs e leia as que ficaram para trás com recordInfo.
Endpoints da conta
Um endpoint da conta é uma URL cadastrada uma vez no console da API, junto com os eventos que ela quer receber. Ele recebe eventos de todas as tarefas da conta, então você não precisa mais passar callBackUrl.
| Evento | Enviado quando |
|---|---|
task.completed | Acontece qualquer um dos quatro abaixo. Assine este, a menos que precise diferenciá-los |
task.succeeded | Uma tarefa terminou com sucesso |
task.failed | Uma tarefa falhou; a reserva foi liberada |
task.expired | Uma tarefa passou do prazo; a reserva foi liberada |
task.canceled | Mantido apenas para registros antigos |
As entregas seguem o formato Standard Webhooks, então bibliotecas prontas funcionam:
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Duas diferenças em relação ao callback de tarefa: assina-se o próprio corpo (sem a etapa de SHA-256), e a chave do HMAC são os bytes obtidos ao decodificar em Base64 a parte do segredo depois de whsec_ — não a string em si. Durante uma rotação do segredo, o cabeçalho pode trazer duas assinaturas separadas por espaço; aceite a requisição se qualquer uma bater. webhook-id não muda entre tentativas: use-o para ignorar duplicatas. O data.object do evento é o registro da tarefa no momento em que o evento foi criado.

