Guías
Webhooks
Recibe webhooks generation.succeeded y generation.failed, y verifica el encabezado HMAC-SHA256 CheaperGen-Signature en Node.js o Python.
En lugar de consultar el estado, proporciona un webhook_url al enviar una generación. Cuando la tarea llegue a un estado final, enviaremos por HTTPS un POST a esa URL con el objeto de generación completo, firmado con el secreto de tu cuenta.
Solicitar un webhook#
Añade webhook_url al cuerpo de cualquier solicitud POST /api/v1/run/{endpoint}. Debe ser una URL HTTPS accesible desde Internet.
curl -X POST https://cheapergen.com/api/v1/run/bytedance/seedance-2.0-fast/text-to-video \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Travelling lento hacia un ceramista que da forma a un jarrón a la hora dorada; polvo en la luz y poca profundidad de campo",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"webhook_url": "https://your-app.com/hooks/cheapergen"
}'Eventos#
generation.succeededevent- La generación terminó.
data.outputcontiene las URL de los archivos multimedia ydata.cost, el cargo final. generation.failedevent- La generación falló.
data.errorindica el motivo (reason,message) y qué hacer (hint,retryable); consulta motivos de fallo. El importe reservado se reembolsó ydata.cost.amount_usdes 0.
Las generaciones canceladas no activan un webhook: como tú las cancelaste, ya lo sabes.
Carga útil (payload)#
El cuerpo es JSON con el evento type y el objeto de generación en el campo data; es el mismo objeto que obtienes de GET /generations/{id}.
{
"type": "generation.succeeded",
"data": {
"id": "gen_8c2QmX1nq0k4Lw7Z",
"object": "generation",
"model": "bytedance/seedance-2.0-fast",
"endpoint": "bytedance/seedance-2.0-fast/text-to-video",
"mode": "text-to-video",
"status": "succeeded",
"input": {
"prompt": "Travelling lento hacia un ceramista que da forma a un jarrón a la hora dorada; polvo en la luz y poca profundidad de campo",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"generate_audio": true
},
"output": {
"video": {
"url": "https://cdn.cheapergen.com/outputs/gen_8c2QmX1nq0k4Lw7Z.mp4",
"content_type": "video/mp4"
}
},
"error": null,
"cost": {
"amount_usd": 0.4494,
"currency": "usd",
"estimated": false
},
"source": "api",
"created_at": "2026-10-15T09:21:04.512Z",
"started_at": "2026-10-15T09:21:09.031Z",
"completed_at": "2026-10-15T09:22:31.870Z",
"retention": "1d",
"expires_at": "2026-10-16T09:22:31.870Z",
"purged_at": null
}
}Cabeceras#
CheaperGen-Signaturestring- Marca de tiempo y firma:
t=<unix seconds>,v1=<hex HMAC-SHA256>. CheaperGen-Webhook-Idstring- Un ID único de esta notificación, por ejemplo
whd_3f9c2a…. Es el mismo en cada reintento: úsalo para eliminar duplicados. Content-Typestringapplication/jsonUser-AgentstringCheaperGen-Webhooks/1.0
Verificar las firmas#
Verifica siempre la firma antes de confiar en un webhook: cualquiera puede enviar una solicitud POST a una URL pública. La cabecera tiene este formato:
CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- Divide el encabezado por
,y lee los valorest(marca de tiempo) yv1(firma). - Construye la cadena firmada: la marca de tiempo, el literal
.y luego cuerpo sin procesar de la solicitud exactamente como se recibió. - Calcula el HMAC-SHA256 de esa cadena con tu secreto de firma como clave y codifica el resultado en hexadecimal.
- Compáralo con
v1mediante una comparación de tiempo constante y rechaza las marcas de tiempo de hace más de unos minutos para evitar repeticiones.
Tu secreto de firma empieza por whsec_ y se muestra en panel, junto a tus claves de API. Guárdalo solo en el servidor, igual que una clave de API.
Tu código también puede obtenerlo con una clave de API: GET /api/v1/webhooks/secret devuelve { "key": "whsec_…" }. Si la cuenta aún no tiene secreto, la primera llamada lo crea.
curl https://cheapergen.com/api/v1/webhooks/secret \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY"Usa el cuerpo sin procesar
Verifica los bytes recibidos. Analizar el JSON y volver a serializarlo cambia los espacios en blanco y el orden de las claves, por lo que la firma no coincidirá.Node.js#
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes
/**
* Verify a CheaperGen webhook.
* @param rawBody the request body exactly as received (string or Buffer) — not re-serialized JSON
* @param header the value of the "CheaperGen-Signature" header
* @param secret your signing secret (whsec_…)
*/
export function verifyCheaperGenSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((kv) => kv.trim().split("=", 2)),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python#
import hashlib, hmac, time
TOLERANCE_SECONDS = 300 # reject deliveries older than 5 minutes
def verify_cheapergen_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""Verify a CheaperGen webhook. raw_body must be the exact bytes received."""
try:
parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Entrega y reintentos#
- Responde con cualquier estado 2xx en un plazo de 10 segundos para confirmar la entrega. No se siguen las redirecciones.
- Si tu endpoint agota el tiempo de espera o responde con algo distinto de 2xx, reintentamos durante un máximo de una hora: aproximadamente 1, 3, 8, 18, 38 y 60 minutos después del primer intento (7 intentos en total) y luego paramos. Una respuesta
410 Gonedetiene los reintentos de inmediato. - Cada intento se vuelve a firmar con un
tnuevo, así que un reintento supera la comprobación de 5 minutos igual que la primera entrega. - Las entregas pueden llegar más de una vez y, rara vez, desordenadas. Haz que tu handler sea idempotente: usa como clave el encabezado
CheaperGen-Webhook-Id(el mismo en cada reintento de una notificación) e ignora las notificaciones que ya procesaste. - Los webhooks son notificaciones, no la única fuente de verdad. Si pierdes uno, siempre puedes consultar la generación en
GET /api/v1/generations/{id}; conciliar periódicamente las tareas que sigan pendientes cubre todos los casos límite.
Pruebas en local#
Las URL de webhook deben ser accesibles públicamente mediante HTTPS. Durante el desarrollo, expón tu servidor local con un túnel (por ejemplo, cloudflared tunnel o ngrok http 3000) y usa su URL HTTPS como webhook_url.
¿Algo no está claro o falta información? Escribe a support@cheapergen.com; leemos todos los mensajes.