Webhooks

Recibe una notificación en el momento en que termina una tarea de generación asíncrona — en lugar de hacer polling

Descripción general

Cuando envías una tarea de generación asíncrona, Atlas Cloud la procesa en segundo plano y el resultado queda disponible algún tiempo después. En lugar de llamar repetidamente al endpoint de Predicciones hasta que la tarea termine, puedes pedirle a Atlas Cloud que te llame de vuelta en el momento en que la tarea alcance un estado terminal.

Para ello, proporciona un webhook_url al enviar la tarea. Cuando la tarea termina — ya sea porque tuvo éxito, falló o expiró por tiempo de espera — Atlas Cloud envía un único POST firmado a esa URL con el resultado final.

Tipos de tareas compatibles

Los webhooks están disponibles para la generación asíncrona de video, imagen y audio. El motor de entrega es agnóstico al tipo de tarea — el event_type (y el encabezado X-AtlasCloud-Webhook-Event) identifica la modalidad: video.task.terminal, image.task.terminal o audio.task.terminal.

Los webhooks complementan el polling — no lo reemplazan. El endpoint de Predicciones sigue funcionando exactamente igual que antes, y la carga útil de un webhook lleva la misma forma de resultado que habrías obtenido consultando. Usa uno, otro o ambos.

Inicio rápido

Añade un campo webhook_url a tu solicitud de envío existente:

curl -X POST https://api.atlascloud.ai/api/v1/model/generateVideo \
  -H "Authorization: Bearer your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
        "model": "bytedance/seedance-2.0/text-to-video",
        "prompt": "A calico kitten chasing a butterfly in a garden, cinematic",
        "duration": 5,
        "resolution": "1080p",
        "webhook_url": "https://your-app.example.com/hooks/atlascloud"
      }'
import requests

response = requests.post(
    "https://api.atlascloud.ai/api/v1/model/generateVideo",
    headers={
        "Authorization": "Bearer your-api-key",
        "Content-Type": "application/json",
    },
    json={
        "model": "bytedance/seedance-2.0/text-to-video",
        "prompt": "A calico kitten chasing a butterfly in a garden, cinematic",
        "duration": 5,
        "resolution": "1080p",
        "webhook_url": "https://your-app.example.com/hooks/atlascloud",
    },
)

print(response.json()["data"]["id"])  # el id de la tarea (session_id)
const res = await fetch("https://api.atlascloud.ai/api/v1/model/generateVideo", {
  method: "POST",
  headers: {
    Authorization: "Bearer your-api-key",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: "bytedance/seedance-2.0/text-to-video",
    prompt: "A calico kitten chasing a butterfly in a garden, cinematic",
    duration: 5,
    resolution: "1080p",
    webhook_url: "https://your-app.example.com/hooks/atlascloud",
  }),
});

const { data } = await res.json();
console.log(data.id); // el id de la tarea (session_id)

El mismo campo webhook_url funciona de forma idéntica en POST /api/v1/model/generateImage (imagen asíncrona, event_type: "image.task.terminal") y en POST /api/v1/model/generateAudio (audio asíncrono, event_type: "audio.task.terminal").

La respuesta al envío no cambia — sigues recibiendo de inmediato un id de tarea (el session_id). El webhook_url es consumido por Atlas Cloud y nunca se reenvía al proveedor de modelos original. Cuando la tarea se completa, tu endpoint recibe un POST.

Requisitos para webhook_url

Tu URL de callback se valida en el momento del envío. Si no supera la validación, la solicitud de envío se rechaza con HTTP 400 y no se crea ninguna tarea (no se te cobra).

ReglaDetalle
EsquemaDebe ser https://. El http:// simple se rechaza.
HostDebe ser una dirección pública y enrutable. Se rechazan los rangos privados, de loopback, link-local y CGNAT (100.64.0.0/10).
LongitudMáximo 1024 caracteres.
AccesibilidadDebe ser accesible desde la internet pública para que Atlas Cloud pueda hacerle un POST.

Estas comprobaciones son una protección contra SSRF. Para el desarrollo local, usa un túnel público (un servicio de pruebas de webhooks, ngrok o un túnel de Cloudflare) en lugar de una dirección privada.

La solicitud de callback

Cuando la tarea alcanza un estado terminal, Atlas Cloud envía un POST con Content-Type: application/json y los siguientes encabezados:

EncabezadoDescripción
X-AtlasCloud-Webhook-IdEl session_id de la tarea — tu clave de correlación e idempotencia.
X-AtlasCloud-Webhook-EventEl tipo de evento, p. ej. video.task.terminal, image.task.terminal o audio.task.terminal.
X-AtlasCloud-Webhook-TimestampSegundos de época Unix en los que se realizó el intento de entrega. Está cubierto por la firma Ed25519.
X-AtlasCloud-Webhook-SignatureHMAC-SHA256 codificado en hexadecimal del cuerpo bruto de la solicitud (esquema HMAC). En el esquema puro Ed25519 este encabezado lleva en su lugar la firma Ed25519 — consulta Verificación de firmas.
X-AtlasCloud-Webhook-Signature-Ed25519Firma Ed25519 en base64url de <timestamp>.<raw_body> (se envía durante la migración de HMAC a Ed25519).
X-AtlasCloud-Webhook-Key-IdEl kid de la clave de firma Ed25519 — coincide con una clave del JWKS.
User-AgentAtlasCloud-Webhook/1.0

Carga útil

{
  "session_id": "string",       // el id de la tarea; tu clave de idempotencia
  "event_type": "string",       // p. ej. "video.task.terminal"
  "status": "OK" | "ERROR",     // resultado de nivel superior — ramifica según esto
  "created_at": 1782295062952,  // hora de creación de la tarea (época en ms)
  "payload": {                  // el resultado, con la misma forma que la API de Predicciones
    "model": "string",
    "status": "completed" | "failed" | "timeout",
    "outputs": ["https://..."], // presente en caso de éxito
    "error_code": 0             // presente en caso de fallo
  },
  "error": "string"             // presente solo cuando status == "ERROR"
}

Ramifica tu manejador según el campo status de nivel superior: OK significa que hay un resultado utilizable en payload.outputs; ERROR significa que la tarea no produjo un resultado y error explica por qué.

Resultados de transcripción de audio

Las tareas de texto a voz y otras tareas de audio generativo devuelven las URLs de sus archivos de audio en payload.outputs, exactamente igual que video e imagen. Para los modelos de voz a texto (transcripción), un payload completado lleva además un objeto estructurado stt_result (texto completo, idioma detectado y marcas de tiempo a nivel de palabra) — el mismo campo que devuelve el endpoint de Predicciones.

Ejemplo de éxito

Una entrega real para una tarea bytedance/seedance-2.0/text-to-video completada:

{
  "session_id": "6a0c02cdb4b147b7bc78881eb7229ece",
  "event_type": "video.task.terminal",
  "status": "OK",
  "created_at": 1782295062952,
  "payload": {
    "model": "bytedance/seedance-2.0/text-to-video",
    "status": "completed",
    "outputs": [
      "https://atlas-media.oss-us-west-1.aliyuncs.com/videos/cgt-20260624-0.mp4"
    ]
  }
}

Ejemplo de fallo

{
  "session_id": "9b2f4e7a1c0d4f5e8a6b3c2d1e0f9a8b",
  "event_type": "video.task.terminal",
  "status": "ERROR",
  "created_at": 1782200000000,
  "payload": {
    "model": "bytedance/seedance-2.0/text-to-video",
    "status": "failed",
    "error_code": 1039
  },
  "error": "the input was rejected by content moderation"
}

Un resultado de tipo timeout se ve igual, con payload.status: "timeout" y un mensaje error genérico.

Verificación de firmas

Verifica siempre la firma antes de confiar en un webhook. Demuestra que la solicitud proviene de Atlas Cloud y que no fue manipulada.

Dos esquemas durante la migración

Atlas Cloud está migrando las firmas de webhooks de un secreto HMAC compartido a Ed25519 con un endpoint JWKS público. Durante la transición, las entregas llevan ambas firmas: una HMAC (X-AtlasCloud-Webhook-Signature) y una Ed25519 (X-AtlasCloud-Webhook-Signature-Ed25519). Prefiere Ed25519 — se verifica con una clave pública que obtienes de una URL, sin ningún secreto compartido que almacenar.

Ed25519 + JWKS (recomendado)

Atlas Cloud firma cada entrega con una clave privada Ed25519 y publica la clave pública correspondiente en un endpoint JWKS. Verificas con esa clave pública — no hay ningún secreto que aprovisionar ni rotar de tu lado.

  • Claves públicas (JWKS): GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json (sin autenticación):
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
    }
  ]
}
  • Mensaje firmado: "<timestamp>.<raw_body>" — el valor de X-AtlasCloud-Webhook-Timestamp, un . literal y después el cuerpo bruto exacto de la solicitud. A diferencia de HMAC, la marca de tiempo está cubierta por la firma, por lo que puedes aplicar una ventana anti-repetición.
  • Encabezado de firma: X-AtlasCloud-Webhook-Signature-Ed25519 (base64url). Una vez que HMAC se retire, la firma Ed25519 pasará a X-AtlasCloud-Webhook-Signature — así que prefiere el encabezado -Ed25519 cuando esté presente y recurre a -Signature.
  • Id de clave: X-AtlasCloud-Webhook-Key-Id es el kid del JWK que firmó la entrega.

Pasos

  1. Lee X-AtlasCloud-Webhook-Timestamp (ts), X-AtlasCloud-Webhook-Key-Id (kid) y el encabezado de la firma Ed25519.
  2. (Recomendado) Rechaza la solicitud si la marca de tiempo se aleja más de ~5 minutos de tu reloj (protección contra repetición).
  3. Obtén el JWKS y selecciona la clave cuyo kid coincida. Cachea el JWKS; ante un kid que no reconozcas, vuelve a obtenerlo una vez (la clave de firma puede haber rotado).
  4. Decodifica el campo x del JWK (base64url) → la clave pública Ed25519 de 32 bytes.
  5. Verifica la firma decodificada desde base64url sobre ts + "." + raw_body.
const crypto = require("crypto");

const JWKS_URL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json";
let jwks = {}; // kid -> jwk

async function publicKey(kid) {
  if (!jwks[kid]) {
    const { keys } = await (await fetch(JWKS_URL)).json();
    jwks = Object.fromEntries(keys.map((k) => [k.kid, k]));
  }
  const jwk = jwks[kid];
  return jwk && crypto.createPublicKey({ key: jwk, format: "jwk" });
}

// req.body debe ser el Buffer del cuerpo BRUTO (p. ej. express.raw()).
async function verifyEd25519(req) {
  const ts = req.get("X-AtlasCloud-Webhook-Timestamp");
  const kid = req.get("X-AtlasCloud-Webhook-Key-Id");
  const sig =
    req.get("X-AtlasCloud-Webhook-Signature-Ed25519") ||
    req.get("X-AtlasCloud-Webhook-Signature");

  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // ventana anti-repetición

  const pub = await publicKey(kid);
  if (!pub) return false;
  const msg = Buffer.concat([Buffer.from(`${ts}.`), req.body]);
  return crypto.verify(null, msg, pub, Buffer.from(sig, "base64url"));
}
import time, json, base64, urllib.request
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature

JWKS_URL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json"
_jwks = {}  # kid -> jwk

def _b64u(s: str) -> bytes:
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def _public_key(kid: str):
    if kid not in _jwks:
        keys = json.load(urllib.request.urlopen(JWKS_URL, timeout=5))["keys"]
        _jwks.clear()
        _jwks.update({k["kid"]: k for k in keys})
    jwk = _jwks.get(kid)
    return Ed25519PublicKey.from_public_bytes(_b64u(jwk["x"])) if jwk else None

def verify_ed25519(headers, raw_body: bytes) -> bool:
    ts = headers["X-AtlasCloud-Webhook-Timestamp"]
    kid = headers["X-AtlasCloud-Webhook-Key-Id"]
    sig = headers.get("X-AtlasCloud-Webhook-Signature-Ed25519") \
        or headers["X-AtlasCloud-Webhook-Signature"]

    if abs(time.time() - int(ts)) > 300:  # ventana anti-repetición
        return False
    pub = _public_key(kid)
    if pub is None:
        return False
    try:
        pub.verify(_b64u(sig), ts.encode() + b"." + raw_body)
        return True
    except InvalidSignature:
        return False
const jwksURL = "https://api.atlascloud.ai/api/v1/webhooks/jwks.json"

// fetchKey devuelve la clave pública en base64url para kid. En código real,
// añade caché y una única reobtención ante fallo (rotación de claves).
func fetchKey(kid string) (string, bool) {
    resp, err := http.Get(jwksURL)
    if err != nil {
        return "", false
    }
    defer resp.Body.Close()
    var set struct {
        Keys []struct{ Kid, X string } `json:"keys"`
    }
    if json.NewDecoder(resp.Body).Decode(&set) != nil {
        return "", false
    }
    for _, k := range set.Keys {
        if k.Kid == kid {
            return k.X, true
        }
    }
    return "", false
}

func verifyEd25519(h http.Header, body []byte) bool {
    ts := h.Get("X-AtlasCloud-Webhook-Timestamp")
    sigB64 := h.Get("X-AtlasCloud-Webhook-Signature-Ed25519")
    if sigB64 == "" {
        sigB64 = h.Get("X-AtlasCloud-Webhook-Signature")
    }
    t, _ := strconv.ParseInt(ts, 10, 64)
    if math.Abs(float64(time.Now().Unix()-t)) > 300 { // ventana anti-repetición
        return false
    }
    xB64, ok := fetchKey(h.Get("X-AtlasCloud-Webhook-Key-Id"))
    if !ok {
        return false
    }
    pub, e1 := base64.RawURLEncoding.DecodeString(xB64)
    sig, e2 := base64.RawURLEncoding.DecodeString(sigB64)
    if e1 != nil || e2 != nil || len(pub) != ed25519.PublicKeySize {
        return false
    }
    return ed25519.Verify(ed25519.PublicKey(pub), append([]byte(ts+"."), body...), sig)
}

La firma demuestra que el callback se originó en Atlas Cloud — no qué cuenta es dueña de la tarea. Como el webhook_url se proporciona por solicitud, correlaciona también el session_id con una tarea que realmente hayas creado antes de actuar sobre el resultado.

HMAC (heredado)

La firma HMAC está en proceso de obsolescencia en favor de Ed25519/JWKS descrito arriba; las nuevas integraciones deberían usar Ed25519. El encabezado HMAC X-AtlasCloud-Webhook-Signature se sigue enviando durante la ventana de migración.

La firma se calcula así:

HMAC-SHA256( signing_secret, raw_request_body )   →   lowercase hex
  • La clave es tu secreto de firma compartido (aprovisionado para tu cuenta).
  • El mensaje son los bytes brutos exactos del cuerpo de la solicitud — verifica antes de cualquier reserialización de JSON, que podría cambiar el orden de bytes o los espacios en blanco.
  • Compara el resultado con el encabezado X-AtlasCloud-Webhook-Signature usando una comparación de tiempo constante.

El encabezado X-AtlasCloud-Webhook-Timestamp es informativo (útil para una comprobación opcional de ventana anti-repetición). No forma parte del contenido firmado — solo se firma el cuerpo bruto.

const crypto = require("crypto");
const express = require("express");
const app = express();

const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;

// Captura el cuerpo BRUTO — no dejes que un parser de JSON se ejecute antes.
app.use("/hooks/atlascloud", express.raw({ type: "*/*" }));

app.post("/hooks/atlascloud", (req, res) => {
  const sig = req.get("X-AtlasCloud-Webhook-Signature") || "";
  const expected = crypto
    .createHmac("sha256", SIGNING_SECRET)
    .update(req.body)               // req.body es un Buffer (bytes brutos)
    .digest("hex");

  // Compara como Buffers y protege según la longitud en BYTES: timingSafeEqual
  // lanza una excepción con entradas de distinta longitud, y un encabezado
  // multibyte malformado puede coincidir en longitud de cadena JS pero diferir
  // en longitud de bytes.
  const sigBuf = Buffer.from(sig);
  const expBuf = Buffer.from(expected);
  const ok =
    sigBuf.length === expBuf.length &&
    crypto.timingSafeEqual(sigBuf, expBuf);
  if (!ok) return res.status(401).send("invalid signature");

  const event = JSON.parse(req.body.toString("utf8"));
  // ... encola por event.session_id y responde rápido ...
  res.status(200).send("ok");
});
import hmac, hashlib, os
from flask import Flask, request, abort

SIGNING_SECRET = os.environ["ATLASCLOUD_WEBHOOK_SECRET"].encode()
app = Flask(__name__)

@app.post("/hooks/atlascloud")
def atlascloud_webhook():
    raw = request.get_data()  # bytes brutos, antes del parseo de JSON
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
    # Compara como bytes: compare_digest sobre str falla con entradas no ASCII.
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    # ... encola por event["session_id"] y responde 200 rápidamente ...
    return "ok", 200
func verify(secret string, body []byte, sigHeader string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(expected), []byte(sigHeader))
}

Semántica de entrega

Confirmar una entrega

Responde con cualquier código de estado 2xx para confirmar la recepción. Cualquier otro código de estado — o un tiempo de espera de conexión — se trata como un fallo y la entrega se reintenta.

Responde rápido (bastante por debajo de unos pocos segundos). Haz el trabajo real de forma asíncrona: verifica la firma, encola el evento con la clave session_id y devuelve 200 de inmediato. Las respuestas lentas corren el riesgo de expirar y provocar reintentos innecesarios.

Reintentos y retroceso

Si una entrega no se confirma, Atlas Cloud reintenta con retroceso exponencial (aproximadamente 10s → 20s → 40s → …, con un tope de unos 30 minutos), hasta un máximo de ~10 intentos. Una vez agotados los intentos, la entrega se marca como no entregable y ya no se reintenta.

Al menos una vez — deduplica por session_id

La entrega es al menos una vez. En casos poco frecuentes puedes recibir el mismo webhook más de una vez. Haz que tu manejador sea idempotente y deduplica por session_id.

El session_id es estable entre reintentos. Trata un webhook con un session_id que ya hayas procesado por completo como una operación sin efecto y devuelve 200.

Puntualidad

La gran mayoría de los webhooks se entregan en cuestión de segundos tras finalizar la tarea. Una red de seguridad de reconciliación integrada garantiza la entrega incluso si se pierde la vía rápida (por ejemplo, durante el despliegue de un servicio), a costa de un retraso de hasta ~30 minutos en esos casos poco comunes. Diseña para una entrega eventual y al menos una vez, no para una entrega instantánea y exactamente una vez.

Mejores prácticas

  • Sirve el callback sobre HTTPS en un host accesible públicamente.
  • Verifica la firma del webhook antes de confiar en el evento — preferiblemente con Ed25519/JWKS (sin secreto que almacenar; verifica con la clave pública obtenida por kid). HMAC es la alternativa heredada durante la ventana de migración.
  • Al verificar con Ed25519, cachea el JWKS y vuelve a obtenerlo ante un kid desconocido; comprueba la firma sobre "<timestamp>.<raw_body>" y aplica una ventana anti-repetición.
  • Responde 2xx rápido; mueve el procesamiento real a una cola en segundo plano.
  • Deduplica por session_id — los manejadores deben ser idempotentes.
  • Ramifica según el status de nivel superior (OK frente a ERROR); lee los resultados desde payload.outputs.
  • No asumas orden ni entrega exactamente una vez; diseña para la entrega al menos una vez.
  • Mantén el endpoint de Predicciones como vía de respaldo y reconciliación.
  • Solo para HMAC heredado: mantén tu secreto de firma en confidencialidad y rótalo si queda expuesto. (Ed25519/JWKS no tiene ningún secreto de tu lado.)

Solución de problemas

SíntomaCausa probable / acción
El envío devuelve 400 webhook_url ... is not a routable public addressEl host es privado/loopback/CGNAT. Usa una URL HTTPS pública o un túnel.
El envío devuelve 400 webhook_url must use httpsCambia el esquema a https://.
El envío devuelve 400 webhook_url exceeds the 1024-character limitAcorta la URL (traslada el estado a tu propio almacén, indexado por session_id).
No se recibe ningún webhookConfirma que el endpoint es accesible públicamente y devuelve 2xx; verifica mediante el endpoint de Predicciones que la tarea realmente alcanzó un estado terminal.
Discrepancia de firma (Ed25519)Firma sobre "<timestamp>.<raw_body>" (no solo el cuerpo), decodifica la firma desde base64url y busca la clave por kid en el JWKS.
Discrepancia de firma (HMAC)Asegúrate de aplicar HMAC a los bytes brutos del cuerpo (no a un objeto JSON reserializado) y de usar el secreto de firma correcto.
El kid no está en el JWKSLa clave de firma rotó — vuelve a obtener el JWKS (no caches una única clave para siempre).
Se recibió el mismo evento dos vecesEs lo esperado con la entrega al menos una vez — deduplica por session_id.

Referencia

  • Envío (con webhook): POST /api/v1/model/generateVideo, POST /api/v1/model/generateImage o POST /api/v1/model/generateAudio — añade webhook_url.
  • Tipos de evento: video.task.terminal, image.task.terminal, audio.task.terminal.
  • JWKS (claves públicas): GET /api/v1/webhooks/jwks.json.
  • Firma (Ed25519, recomendada): Ed25519 en base64url sobre "<timestamp>.<raw_body>", en X-AtlasCloud-Webhook-Signature-Ed25519 (id de clave en X-AtlasCloud-Webhook-Key-Id).
  • Firma (HMAC, heredada): HMAC-SHA256(signing_secret, raw_body), en hexadecimal, en X-AtlasCloud-Webhook-Signature.
  • Clave de idempotencia: session_id (también en X-AtlasCloud-Webhook-Id).
  • Alternativa por polling: Predicciones.