La generación de vídeo e imagen es asíncrona: envías la petición, recibes un ID de tarea y el resultado llega un poco después. En lugar de consultar el estado una y otra vez, deja que enviemos el resultado a tu servidor cuando termine la tarea y que te avisemos cuando se esté agotando tu saldo.
Dónde encajan los webhooks y cómo configurarlos en tres pasos: lee la guía →
Dos formas de recibir eventos
- URL predeterminada de la cuenta: en la consola, ve a Configuración → Webhook, escribe tu URL de destino y marca los eventos que quieras. A partir de ahí, cada tarea que envíes por la API notificará su resultado al terminar. Abrir la configuración de webhooks →
- callback_url en cada petición: añade el campo callback_url al enviar una tarea de vídeo o imagen. El resultado de esa tarea (éxito o fallo) se envía solo a esa URL, sin importar lo que esté marcado en Configuración.
callback_url debe ser una URL http(s) accesible públicamente, de 2000 caracteres como máximo; también se acepta callBackUrl. Las dos vías usan el mismo secreto de firma, que encontrarás en Configuración → Webhook.
curl https://nezhagate.com/v1/videos/generations -H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' -d '{"model": "seedance-2.5", "prompt": "a paper boat drifting down a rainy street", "duration": 5, "callback_url": "https://example.com/webhooks/nezhagate"}'Eventos
| type | Se envía cuando | Contenido de data |
|---|---|---|
video.completed | se genera un vídeo | exactamente el mismo JSON que devuelven los endpoints de tareas (GET /v1/videos/jobs/{id}, GET /v1/images/jobs/{id}) |
video.failed | falla la generación de un vídeo (reembolso íntegro) | |
image.completed | se genera una imagen | |
image.failed | falla la generación de una imagen (reembolso íntegro) | |
balance.low | tu saldo baja del número de créditos que configuraste (una vez por cada bajada; se reactiva tras una recarga) | balance_credits (saldo actual, en créditos) y threshold_credits (tu umbral de aviso) |
webhook.test | haces clic en «Enviar evento de prueba» en Configuración | un mensaje breve para confirmar que recibes los eventos y puedes verificarlos |
Formato de la petición
Enviamos a tu URL una petición POST con cuerpo JSON y estas cabeceras:
webhook-id | ID del evento. No cambia entre reintentos; úsalo para descartar duplicados. |
webhook-timestamp | Momento en que se envió este intento (segundos Unix). Cambia en cada intento. |
webhook-signature | La firma: v1, seguido de un HMAC-SHA256 codificado en Base64. |
Content-Type | Siempre application/json. |
Ejemplos del cuerpo
Vídeo generado:
{
"id": "evt_5f0c3d2e8a9b4c71d2e3f405",
"object": "event",
"type": "video.completed",
"created_at": 1791200000,
"data": {
"id": "img_3f9a...c2",
"object": "video.generation.job",
"status": "succeeded",
"created": 1791199400,
"model": "seedance-2.5",
"data": [{ "url": "https://img.nezhagate.com/i/9f86d081a8....mp4" }],
"usage": { "clips": 1, "model": "seedance-2.5" }
}
}Si la tarea falla, data incluye error (igual que en los endpoints de tareas) y el cobro ya se ha reembolsado íntegramente:
{
"id": "evt_8b1e47c09d3a2f6e5b7c1d90",
"object": "event",
"type": "image.failed",
"created_at": 1791200120,
"data": {
"id": "img_7c21...e4",
"object": "image.generation.job",
"status": "failed",
"created": 1791200050,
"model": "gpt-image-2",
"error": { "message": "...", "type": "upstream_error", "code": "upstream_error" }
}
}{
"id": "evt_1a2b3c4d5e6f708192a3b4c5",
"object": "event",
"type": "balance.low",
"created_at": 1791203000,
"data": { "balance_credits": 412.5, "threshold_credits": 500 }
}Verificación de firmas
Los eventos se firman según la especificación Standard Webhooks (la misma que usan los webhooks de OpenAI). Para verificarlos:
- Lee las cabeceras webhook-id, webhook-timestamp y webhook-signature, y el cuerpo de la petición sin procesar (no lo conviertas a JSON para volver a serializarlo).
- Construye el contenido firmado: webhook-id + "." + webhook-timestamp + "." + cuerpo sin procesar.
- La clave es la parte que sigue a whsec_, decodificada de Base64. Calcula con ella el HMAC-SHA256 y codifica el resultado en Base64.
- webhook-signature puede contener varias firmas (separadas por espacios, cada una empieza por v1,); acepta la petición si coincide cualquiera. Compara en tiempo constante.
- Para bloquear ataques de repetición, rechaza las marcas de tiempo que difieran más de 5 minutos de tu reloj.
import base64, hashlib, hmac, time
from flask import Flask, abort, request
SECRET = "whsec_..." # Settings -> Webhook -> Signing secret
KEY = base64.b64decode(SECRET[len("whsec_"):])
def verify(headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
signed = f"{msg_id}.{ts}.".encode() + body
expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
return any(sig.startswith("v1,") and hmac.compare_digest(sig[3:], expected)
for sig in headers.get("webhook-signature", "").split())
app = Flask(__name__)
@app.post("/webhooks/nezhagate")
def webhook():
if not verify(request.headers, request.get_data()):
abort(401)
event = request.get_json()
if event["type"] == "video.completed":
print(event["data"]["id"], event["data"]["data"][0]["url"])
return "", 204import crypto from "node:crypto";
import express from "express";
const SECRET = "whsec_..."; // Settings -> Webhook -> Signing secret
const KEY = Buffer.from(SECRET.slice("whsec_".length), "base64");
const app = express();
app.post("/webhooks/nezhagate", express.raw({ type: "application/json" }), (req, res) => {
const id = req.header("webhook-id"), ts = req.header("webhook-timestamp");
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const expected = Buffer.from(
crypto.createHmac("sha256", KEY).update(`${id}.${ts}.`).update(req.body).digest("base64"));
const ok = (req.header("webhook-signature") || "").split(" ").some((sig) => {
const got = Buffer.from(sig.slice(3));
return sig.startsWith("v1,") && got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data.id);
res.sendStatus(204);
});
app.listen(3000);También puedes usar la biblioteca oficial de Standard Webhooks para tu lenguaje (Python: pip install standardwebhooks; Node: npm install standardwebhooks).
Respuestas y reintentos
- Cualquier 2xx en un plazo de 10 segundos cuenta como entregado; el cuerpo de la respuesta no importa. Responde primero con 2xx y procesa el evento después.
- Si no recibimos un 2xx (tiempo de espera agotado, error de conexión, 3xx, 4xx, 5xx), reintentamos tras 1 min, 5 min, 30 min, 2 h y 6 h, y después lo marcamos como fallido.
- Un evento puede llegar más de una vez y en cualquier orden: descarta duplicados por webhook-id y guíate por data.status.
- En Configuración → Webhook verás los envíos de los últimos 30 días con el resultado de cada uno, y podrás reenviar cualquiera.
Seguridad
- Solo direcciones públicas: se rechazan las direcciones privadas, locales y reservadas.
- No se siguen redirecciones: una respuesta 3xx cuenta como fallo.
- Guarda el secreto de firma con el mismo cuidado que una API key. Si se filtra, regenéralo en Configuración; el anterior deja de funcionar al instante.
Pruebas
Guarda tu URL en Configuración → Webhook y haz clic en «Enviar evento de prueba». Enviaremos al instante un evento webhook.test y te mostraremos el código de estado con el que respondió tu servidor y cuánto tardó.