NezhaGateNezhaGate
API Documentation

Webhooks: recibe un aviso cuando termine una tarea

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

  1. 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 →
  2. 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 · Enviar una tarea con callback_url:
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

typeSe envía cuandoContenido de data
video.completedse genera un vídeoexactamente el mismo JSON que devuelven los endpoints de tareas (GET /v1/videos/jobs/{id}, GET /v1/images/jobs/{id})
video.failedfalla la generación de un vídeo (reembolso íntegro)
image.completedse genera una imagen
image.failedfalla la generación de una imagen (reembolso íntegro)
balance.lowtu 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.testhaces clic en «Enviar evento de prueba» en Configuraciónun 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-idID del evento. No cambia entre reintentos; úsalo para descartar duplicados.
webhook-timestampMomento en que se envió este intento (segundos Unix). Cambia en cada intento.
webhook-signatureLa firma: v1, seguido de un HMAC-SHA256 codificado en Base64.
Content-TypeSiempre application/json.

Ejemplos del cuerpo

Vídeo generado:

video.completed
{
  "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:

image.failed
{
  "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" }
  }
}
balance.low
{
  "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:

  1. 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).
  2. Construye el contenido firmado: webhook-id + "." + webhook-timestamp + "." + cuerpo sin procesar.
  3. La clave es la parte que sigue a whsec_, decodificada de Base64. Calcula con ella el HMAC-SHA256 y codifica el resultado en Base64.
  4. 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.
  5. Para bloquear ataques de repetición, rechaza las marcas de tiempo que difieran más de 5 minutos de tu reloj.
Python · Flask
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 "", 204
Node.js · Express
import 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ó.

Abrir la configuración de webhooks