CheaperGen

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.output contiene las URL de los archivos multimedia y data.cost, el cargo final.
generation.failedevent
La generación falló. data.error indica el motivo (reason, message) y qué hacer (hint, retryable); consulta motivos de fallo. El importe reservado se reembolsó y data.cost.amount_usd es 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}.

POST https://your-app.com/hooks/cheapergen
{
  "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-Typestring
application/json
User-Agentstring
CheaperGen-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
  1. Divide el encabezado por , y lee los valores t (marca de tiempo) y v1 (firma).
  2. Construye la cadena firmada: la marca de tiempo, el literal . y luego cuerpo sin procesar de la solicitud exactamente como se recibió.
  3. Calcula el HMAC-SHA256 de esa cadena con tu secreto de firma como clave y codifica el resultado en hexadecimal.
  4. Compáralo con v1 mediante 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 Gone detiene los reintentos de inmediato.
  • Cada intento se vuelve a firmar con un t nuevo, 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.