Webhooks

Soyez notifié dès qu'une tâche de génération asynchrone se termine — au lieu d'interroger en boucle

Vue d'ensemble

Lorsque vous soumettez une tâche de génération asynchrone, Atlas Cloud la traite en arrière-plan et le résultat devient disponible quelque temps plus tard. Au lieu d'appeler à répétition le point d'accès Prédictions jusqu'à ce que la tâche se termine, vous pouvez demander à Atlas Cloud de vous rappeler dès que la tâche atteint un état terminal.

Pour cela, vous fournissez un webhook_url au moment de soumettre la tâche. Lorsque la tâche se termine — qu'elle ait réussi, échoué ou expiré — Atlas Cloud envoie un unique POST signé vers cette URL, contenant le résultat final.

Types de tâches pris en charge

Les webhooks sont disponibles pour la génération asynchrone de vidéo, d'image et d'audio. Le moteur de distribution est agnostique du type de tâche — le event_type (et l'en-tête X-AtlasCloud-Webhook-Event) identifie la modalité : video.task.terminal, image.task.terminal ou audio.task.terminal.

Les webhooks complètent l'interrogation — ils ne la remplacent pas. Le point d'accès Prédictions continue de fonctionner exactement comme avant, et la charge utile d'un webhook contient le même format de résultat que celui que vous auriez obtenu par interrogation. Utilisez l'un, l'autre, ou les deux.

Démarrage rapide

Ajoutez un champ webhook_url à votre requête de soumission existante :

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"])  # l'id de la tâche (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); // l'id de la tâche (session_id)

Le même champ webhook_url fonctionne de manière identique sur POST /api/v1/model/generateImage (image asynchrone, event_type: "image.task.terminal") et POST /api/v1/model/generateAudio (audio asynchrone, event_type: "audio.task.terminal").

La réponse de soumission est inchangée — vous recevez toujours immédiatement un id de tâche (le session_id). Le webhook_url est consommé par Atlas Cloud et n'est jamais transmis au fournisseur de modèle en amont. Lorsque la tâche se termine, votre point d'accès reçoit un POST.

Exigences pour webhook_url

Votre URL de rappel est validée au moment de la soumission. Si elle échoue à la validation, la requête de soumission est rejetée avec HTTP 400 et aucune tâche n'est créée (vous n'êtes pas facturé).

RègleDétail
SchémaDoit être https://. Le http:// simple est rejeté.
HôteDoit être une adresse publique et routable. Les plages privées, de bouclage, link-local et CGNAT (100.64.0.0/10) sont rejetées.
Longueur1024 caractères maximum.
AccessibilitéDoit être joignable depuis l'internet public afin qu'Atlas Cloud puisse y envoyer un POST.

Ces contrôles constituent une protection contre le SSRF. Pour le développement local, utilisez un tunnel public (un service de test de webhooks, ngrok ou un tunnel Cloudflare) plutôt qu'une adresse privée.

La requête de rappel

Lorsque la tâche atteint un état terminal, Atlas Cloud envoie un POST avec Content-Type: application/json et les en-têtes suivants :

En-têteDescription
X-AtlasCloud-Webhook-IdLe session_id de la tâche — votre clé de corrélation et d'idempotence.
X-AtlasCloud-Webhook-EventLe type d'événement, par ex. video.task.terminal, image.task.terminal ou audio.task.terminal.
X-AtlasCloud-Webhook-TimestampHorodatage Unix en secondes au moment de la tentative de distribution. Couvert par la signature Ed25519.
X-AtlasCloud-Webhook-SignatureHMAC-SHA256 encodé en hexadécimal du corps brut de la requête (schéma HMAC). Dans le schéma Ed25519 pur, cet en-tête transporte à la place la signature Ed25519 — voir Vérifier les signatures.
X-AtlasCloud-Webhook-Signature-Ed25519Signature Ed25519 en base64url de <timestamp>.<raw_body> (envoyée pendant la migration HMAC→Ed25519).
X-AtlasCloud-Webhook-Key-IdLe kid de la clé de signature Ed25519 — correspond à une clé du JWKS.
User-AgentAtlasCloud-Webhook/1.0

Charge utile

{
  "session_id": "string",       // l'id de la tâche ; votre clé d'idempotence
  "event_type": "string",       // par ex. "video.task.terminal"
  "status": "OK" | "ERROR",     // résultat de haut niveau — branchez dessus
  "created_at": 1782295062952,  // date de création de la tâche (epoch en ms)
  "payload": {                  // le résultat, même format que l'API Predictions
    "model": "string",
    "status": "completed" | "failed" | "timeout",
    "outputs": ["https://..."], // présent en cas de succès
    "error_code": 0             // présent en cas d'échec
  },
  "error": "string"             // présent uniquement lorsque status == "ERROR"
}

Branchez votre gestionnaire sur le champ status de premier niveau : OK signifie qu'un résultat exploitable se trouve dans payload.outputs ; ERROR signifie que la tâche n'a pas produit de résultat et error en explique la raison.

Résultats de transcription audio

Les tâches de synthèse vocale et les autres tâches audio génératives renvoient les URL de leurs fichiers audio dans payload.outputs, exactement comme la vidéo et l'image. Pour les modèles de reconnaissance vocale (transcription), un payload terminé contient en plus un objet structuré stt_result (texte complet, langue détectée et horodatages au niveau des mots) — le même champ que celui renvoyé par le point d'accès Prédictions.

Exemple de succès

Une distribution réelle pour une tâche bytedance/seedance-2.0/text-to-video terminée :

{
  "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"
    ]
  }
}

Exemple d'échec

{
  "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 résultat de type timeout a la même forme, avec payload.status: "timeout" et un message error générique.

Vérifier les signatures

Vérifiez toujours la signature avant de faire confiance à un webhook. Elle prouve que la requête provient bien d'Atlas Cloud et qu'elle n'a pas été altérée.

Deux schémas pendant la migration

Atlas Cloud fait migrer les signatures de webhooks d'un secret HMAC partagé vers Ed25519 avec un point d'accès JWKS public. Pendant la transition, les distributions transportent à la fois une signature HMAC (X-AtlasCloud-Webhook-Signature) et une signature Ed25519 (X-AtlasCloud-Webhook-Signature-Ed25519). Privilégiez Ed25519 — la vérification s'appuie sur une clé publique que vous récupérez depuis une URL, sans aucun secret partagé à stocker.

Ed25519 + JWKS (recommandé)

Atlas Cloud signe chaque distribution avec une clé privée Ed25519 et publie la clé publique correspondante sur un point d'accès JWKS. Vous vérifiez la signature avec cette clé publique — il n'y a aucun secret à provisionner ni à renouveler de votre côté.

  • Clés publiques (JWKS) : GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json (sans authentification) :
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
    }
  ]
}
  • Message signé : "<timestamp>.<raw_body>" — la valeur de X-AtlasCloud-Webhook-Timestamp, un . littéral, puis le corps brut exact de la requête. Contrairement au HMAC, l'horodatage est couvert par la signature, ce qui vous permet d'appliquer une fenêtre anti-rejeu.
  • En-tête de signature : X-AtlasCloud-Webhook-Signature-Ed25519 (base64url). Une fois le HMAC retiré, la signature Ed25519 passera dans X-AtlasCloud-Webhook-Signature — privilégiez donc l'en-tête -Ed25519 lorsqu'il est présent, avec repli sur -Signature.
  • Identifiant de clé : X-AtlasCloud-Webhook-Key-Id est le kid du JWK qui a signé la distribution.

Étapes

  1. Lisez X-AtlasCloud-Webhook-Timestamp (ts), X-AtlasCloud-Webhook-Key-Id (kid) et l'en-tête de signature Ed25519.
  2. (Recommandé) Rejetez la requête si l'horodatage s'écarte de plus de ~5 minutes de votre horloge (protection anti-rejeu).
  3. Récupérez le JWKS et sélectionnez la clé dont le kid correspond. Mettez le JWKS en cache ; face à un kid que vous ne reconnaissez pas, récupérez-le à nouveau une fois (la clé de signature a peut-être été renouvelée).
  4. Décodez le x du JWK (base64url) → la clé publique Ed25519 de 32 octets.
  5. Vérifiez la signature décodée en base64url sur 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 doit être le Buffer du corps BRUT (par ex. 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; // fenêtre anti-rejeu

  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:  # fenêtre anti-rejeu
        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 renvoie la clé publique base64url correspondant au kid. En production,
// ajoutez un cache + une unique nouvelle récupération en cas d'absence (rotation de clé).
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 { // fenêtre anti-rejeu
        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 signature prouve que le rappel provient d'Atlas Cloud — et non quel compte possède la tâche. Comme le webhook_url est fourni requête par requête, corrélez également le session_id à une tâche que vous avez réellement créée avant d'agir sur le résultat.

HMAC (hérité)

La signature HMAC est en cours d'abandon au profit d'Ed25519/JWKS ci-dessus ; les nouvelles intégrations doivent utiliser Ed25519. L'en-tête HMAC X-AtlasCloud-Webhook-Signature continue d'être envoyé pendant la fenêtre de migration.

La signature est calculée ainsi :

HMAC-SHA256( signing_secret, raw_request_body )   →   lowercase hex
  • La clé est votre secret de signature partagé (provisionné pour votre compte).
  • Le message correspond aux octets bruts exacts du corps de la requête — vérifiez avant toute re-sérialisation JSON, qui pourrait modifier l'ordre des octets ou les espaces.
  • Comparez le résultat à l'en-tête X-AtlasCloud-Webhook-Signature avec une comparaison à temps constant.

L'en-tête X-AtlasCloud-Webhook-Timestamp est purement informatif (utile pour une vérification facultative de fenêtre anti-rejeu). Il ne fait pas partie du contenu signé — seul le corps brut est signé.

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

const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;

// Capturez le corps BRUT — ne laissez pas un parseur JSON s'exécuter avant.
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 est un Buffer (octets bruts)
    .digest("hex");

  // Comparez des Buffers et contrôlez la longueur en OCTETS : timingSafeEqual
  // lève une exception si les longueurs diffèrent, et un en-tête multi-octets
  // malformé peut correspondre en longueur de chaîne JS tout en différant en
  // longueur d'octets.
  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"));
  // ... mettez en file d'attente par event.session_id, puis répondez vite ...
  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()  # octets bruts, avant l'analyse JSON
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
    # Comparez des octets : compare_digest sur str lève une exception si l'entrée n'est pas ASCII.
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    # ... mettez en file d'attente par event["session_id"], puis répondez 200 rapidement ...
    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))
}

Sémantique de distribution

Accuser réception d'une distribution

Répondez avec n'importe quel code de statut 2xx pour accuser réception. Tout autre code de statut — ou un délai de connexion dépassé — est traité comme un échec et la distribution est réessayée.

Répondez rapidement (bien en deçà de quelques secondes). Effectuez le vrai travail de manière asynchrone : vérifiez la signature, mettez l'événement en file d'attente avec le session_id comme clé, puis renvoyez 200 immédiatement. Des réponses lentes risquent d'expirer et de déclencher des tentatives inutiles.

Tentatives et temporisation

Si une distribution n'est pas acquittée, Atlas Cloud réessaie avec une temporisation exponentielle (environ 10s → 20s → 40s → …, plafonnée à environ 30 minutes), jusqu'à ~10 tentatives. Une fois les tentatives épuisées, la distribution est marquée comme non distribuable et n'est plus réessayée.

Au moins une fois — dédupliquez sur session_id

La distribution est au moins une fois (at-least-once). Dans de rares cas, vous pouvez recevoir plusieurs fois le même webhook. Rendez votre gestionnaire idempotent et dédupliquez sur session_id.

Le session_id reste stable d'une tentative à l'autre. Traitez un webhook portant un session_id que vous avez déjà entièrement traité comme une opération sans effet et renvoyez 200.

Délais de distribution

La grande majorité des webhooks sont distribués quelques secondes après la fin de la tâche. Un filet de sécurité de réconciliation intégré garantit la distribution même si le chemin rapide est manqué (par exemple pendant un déploiement de service), au prix d'un délai pouvant atteindre ~30 minutes dans ces cas peu fréquents. Concevez pour une distribution à terme et au moins une fois, plutôt qu'instantanée et exactement une fois.

Bonnes pratiques

  • Exposez le point de rappel en HTTPS sur un hôte publiquement accessible.
  • Vérifiez la signature du webhook avant de faire confiance à l'événement — de préférence en Ed25519/JWKS (aucun secret à stocker ; vérification avec la clé publique récupérée via le kid). Le HMAC est le repli hérité pendant la fenêtre de migration.
  • Lors de la vérification Ed25519, mettez le JWKS en cache et récupérez-le à nouveau sur un kid inconnu ; vérifiez la signature sur "<timestamp>.<raw_body>" et appliquez une fenêtre anti-rejeu.
  • Répondez 2xx rapidement ; déplacez le traitement réel vers une file d'attente en arrière-plan.
  • Dédupliquez sur session_id — les gestionnaires doivent être idempotents.
  • Branchez sur le status de premier niveau (OK ou ERROR) ; lisez les résultats dans payload.outputs.
  • Ne présumez ni d'un ordre ni d'une livraison exactement une fois ; concevez pour du au moins une fois.
  • Gardez le point d'accès Prédictions comme solution de repli / voie de réconciliation.
  • HMAC hérité uniquement : gardez votre secret de signature confidentiel et renouvelez-le en cas d'exposition. (Ed25519/JWKS n'implique aucun secret de votre côté.)

Dépannage

SymptômeCause probable / action
La soumission renvoie 400 webhook_url ... is not a routable public addressL'hôte est privé, de bouclage ou CGNAT. Utilisez une URL HTTPS publique ou un tunnel.
La soumission renvoie 400 webhook_url must use httpsPassez le schéma à https://.
La soumission renvoie 400 webhook_url exceeds the 1024-character limitRaccourcissez l'URL (déplacez l'état dans votre propre stockage, indexé par session_id).
Aucun webhook reçuVérifiez que le point d'accès est publiquement joignable et renvoie 2xx ; confirmez via le point d'accès Prédictions que la tâche a bien atteint un état terminal.
Signature non concordante (Ed25519)Signez sur "<timestamp>.<raw_body>" (et non sur le corps seul), décodez la signature en base64url et recherchez la clé par kid dans le JWKS.
Signature non concordante (HMAC)Assurez-vous de calculer le HMAC sur les octets bruts du corps (et non sur un objet JSON re-sérialisé) et d'utiliser le bon secret de signature.
kid absent du JWKSLa clé de signature a été renouvelée — récupérez à nouveau le JWKS (ne mettez pas une seule clé en cache indéfiniment).
Même événement reçu deux foisComportement attendu avec une distribution au moins une fois — dédupliquez sur session_id.

Référence

  • Soumission (avec webhook) : POST /api/v1/model/generateVideo, POST /api/v1/model/generateImage ou POST /api/v1/model/generateAudio — ajoutez webhook_url.
  • Types d'événements : video.task.terminal, image.task.terminal, audio.task.terminal.
  • JWKS (clés publiques) : GET /api/v1/webhooks/jwks.json.
  • Signature (Ed25519, recommandée) : Ed25519 en base64url sur "<timestamp>.<raw_body>", dans X-AtlasCloud-Webhook-Signature-Ed25519 (identifiant de clé dans X-AtlasCloud-Webhook-Key-Id).
  • Signature (HMAC, héritée) : HMAC-SHA256(signing_secret, raw_body), en hexadécimal, dans X-AtlasCloud-Webhook-Signature.
  • Clé d'idempotence : session_id (également dans X-AtlasCloud-Webhook-Id).
  • Alternative par interrogation : Prédictions.