Tareas y webhooks
La generación de contenido multimedia (vídeo, imágenes, música) es asíncrona: se crea una tarea (job) y se obtiene el resultado cuando está listo. Hay dos formas de saber que una tarea ha finalizado: sondeo (polling) y webhook (push). A continuación se explica cómo funciona una tarea y cómo obtener el resultado de forma fiable.
Ciclo de vida de una tarea
POST /media/generate → status: processing ──► done (result_url listo)
│ └─► failed (error, se reembolsan los fondos)
└── (opcional) callback_url → webhook push al completar o fallar- Creación.
POST /media/generatecon el modelo y los parámetros devuelveidy un enlace para sondear:
{ "id": "beefb531-…", "model": "image/nano-banana-2", "status": "processing",
"poll": "https://nordrouter.com/media/job/beefb531-…" }Espera. La tarea pasa de
processingadone(ofailed). Las imágenes tardan segundos; el vídeo y la música, desde decenas de segundos hasta un par de minutos.Resultado. Cuando la tarea termina,
result_urlcontiene un enlace anordrouter.com. El archivo se conserva durante un tiempo limitado (retention_daysdeGET /media/models); descárguelo para guardarlo.
El formato de respuesta de GET /media/job/:id (y del cuerpo del webhook; véase más abajo) es el mismo:
{ "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }status:processing|done|failed.result_url: solo está presente cuando el estado esdone; en caso contrario, esnull.result_url_2: segundo tema en los modelos de música (Suno); en caso contrario,null.results: todos los archivos de la tarea:[{ "index": 1, "name": "Resultado 1", "ext": "png", "url": "…" }](en 3D incluye el modelo y la vista previa; al separar pistas, las pistas «Voz», «Instrumental»…).cost_usd: coste real (se cobra según el uso; las generaciones fallidas no se cobran y se reembolsa la reserva).error: texto del error cuando el estado esfailed; en caso contrario,null.
Método 1. Sondeo (polling)
La forma más sencilla es consultar periódicamente GET /media/job/:id hasta que status sea done o failed:
curl https://nordrouter.com/media/job/beefb531-…Consulte cada 2–3 segundos. El sondeo siempre funciona y no requiere una dirección pública, por lo que es adecuado para scripts, notebooks y CI.
Método 2. Webhooks (push)
Para evitar el sondeo, envía callback_url en POST /media/generate y enviaremos un POST a tu dirección cuando finalice la tarea:
curl https://nordrouter.com/media/generate \
-H "Authorization: Bearer sk-nr-YOUR-KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "image/nano-banana-2",
"input": { "prompt": "…" },
"callback_url": "https://your-server.com/webhooks/nordrouter",
"callback_secret": "cualquier-cadena-tuya"
}'Si se acepta callback_url, la respuesta de creación contiene "webhook": "registered". callback_url se valida de inmediato al crear la tarea: si no cumple los requisitos (no usa https o es una dirección privada/local), la solicitud devolverá 400 con el motivo ({ "error": "callback_url отклонён: …", "code": "invalid_callback" }) y no se creará la tarea.
Cuerpo del webhook
Cuando la tarea alcanza el estado done/failed, tu callback_url recibe un POST con el mismo cuerpo que GET /media/job/:id, más el campo event:
{ "event": "media.completed", "id": "beefb531-…", "status": "done",
"result_url": "https://nordrouter.com/media/file/beefb531-…",
"cost_usd": 0.052, "error": null }event—media.completed(éxito) omedia.failed(error).
Verificación de la firma
Si has definido callback_secret, la cabecera X-NR-Signature contiene sha256=<hmac>: el HMAC-SHA256 del cuerpo sin procesar de la solicitud, usando tu secreto como clave. Verifícalo para comprobar que la solicitud proviene de nosotros y no es una falsificación:
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header) # comparación en tiempo constanteimport crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Calcula el HMAC sobre los bytes exactamente como llegaron, antes de analizar el JSON; de lo contrario, la firma no coincidirá.
Receptor listo en 10 líneas
Un servidor funcional que recibe el webhook, verifica la firma y responde con 2xx. El secreto es el mismo que enviaste en callback_secret.
Node.js / Express:
import express from "express";
import crypto from "node:crypto";
const SECRET = "cualquier-cadena-tuya"; // = tu callback_secret
const app = express();
app.post("/webhooks/nordrouter",
express.raw({ type: "application/json" }), // cuerpo sin procesar: obligatorio para la firma
(req, res) => {
const expected = "sha256=" + crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const got = req.headers["x-nr-signature"] || "";
if (expected.length !== got.length || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got)))
return res.sendStatus(401); // la firma no coincide: la solicitud no es nuestra
const job = JSON.parse(req.body);
if (job.event === "media.completed") console.log("listo:", job.result_url, "$" + job.cost_usd);
res.sendStatus(200); // responde con 2xx; de lo contrario, se reintentará
});
app.listen(3000);Python / FastAPI:
import hmac, hashlib
from fastapi import FastAPI, Request, Response
SECRET = b"cualquier-cadena-tuya" # = tu callback_secret
app = FastAPI()
@app.post("/webhooks/nordrouter")
async def hook(request: Request):
raw = await request.body() # bytes sin procesar: obligatorios para la firma
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-NR-Signature", "")):
return Response(status_code=401) # la firma no coincide: la solicitud no es nuestra
job = await request.json()
if job["event"] == "media.completed":
print("listo:", job["result_url"], "$", job["cost_usd"])
return Response(status_code=200) # responde con 2xx; de lo contrario, se reintentaráPrueba local
callback_url debe ser una dirección https pública. Para desarrollar en un portátil, crea un túnel, por ejemplo con ngrok http 3000, y usa la dirección https que te proporcione como callback_url.
Entrega y fiabilidad
- Hasta 4 intentos con pausas crecientes (≈3 → 6 → 9 s) y un tiempo de espera de 15 s por intento. Responde con
2xx; de lo contrario, se reintentará. - No se siguen las redirecciones: responde directamente en
callback_url. - La entrega es best-effort: si fallan los 4 intentos (tu servidor estaba caído), el webhook no se reenviará más tarde. Por lo tanto:
Los webhooks no sustituyen al sondeo
El webhook acelera la notificación, pero no ofrece garantías. Mantén GET /media/job/:id como alternativa: si la notificación push no llega en un plazo razonable, consulta el estado por tu cuenta. La idempotencia corre de tu parte: una misma tarea puede llegar por webhook y detectarse mediante sondeo; usa id como referencia.
Requisitos de callback_url
- Solo
https://. - Solo direcciones públicas. Las direcciones locales y privadas (
localhost,*.local,10.x,172.16–31.x,192.168.x,127.x,169.254.x,100.64–127.x, loopback/ULA/link-local de IPv6) se rechazan como protección contra SSRF. La dirección también se vuelve a comprobar antes de cada envío (protección contra DNS rebinding).
Qué elegir
| Sondeo | Webhook | |
|---|---|---|
Dirección https pública | no es necesaria | es necesaria |
| Latencia de recepción | hasta el intervalo de sondeo | casi instantánea |
| Garantía | tú tienes el control | best-effort + sondeo como respaldo |
| Dónde resulta útil | scripts, CI, portátil | servidores, backends de producción |
Recomendación: en el backend, webhook + sondeo como respaldo; en un script/portátil, solo sondeo.