Webhook

Ricevi una notifica nel momento esatto in cui un'attività di generazione asincrona termina — invece di fare polling

Panoramica

Quando invii un'attività di generazione asincrona, Atlas Cloud la elabora in background e il risultato diventa disponibile qualche tempo dopo. Invece di chiamare ripetutamente l'endpoint Predizioni finché l'attività non termina, puoi chiedere ad Atlas Cloud di richiamarti nel momento in cui l'attività raggiunge uno stato terminale.

Per farlo, fornisci un webhook_url al momento dell'invio dell'attività. Quando l'attività termina — che sia riuscita, fallita o scaduta — Atlas Cloud invia un singolo POST firmato a quell'URL contenente il risultato finale.

Tipi di attività supportati

I webhook sono disponibili per la generazione asincrona di video, immagini e audio. Il motore di consegna è indipendente dal tipo di attività — l'event_type (e l'header X-AtlasCloud-Webhook-Event) identifica la modalità: video.task.terminal, image.task.terminal o audio.task.terminal.

I webhook completano il polling — non lo sostituiscono. L'endpoint Predizioni continua a funzionare esattamente come prima, e il payload di un webhook contiene la stessa struttura di risultato che avresti ottenuto con il polling. Usa l'uno, l'altro o entrambi.

Avvio rapido

Aggiungi un campo webhook_url alla tua richiesta di invio esistente:

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 dell'attività (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 dell'attività (session_id)

Lo stesso campo webhook_url funziona in modo identico su POST /api/v1/model/generateImage (immagine asincrona, event_type: "image.task.terminal") e POST /api/v1/model/generateAudio (audio asincrono, event_type: "audio.task.terminal").

La risposta di invio è invariata — ricevi comunque subito un id dell'attività (il session_id). Il webhook_url viene consumato da Atlas Cloud e non viene mai inoltrato al provider del modello a monte. Quando l'attività si completa, il tuo endpoint riceve un POST.

Requisiti per webhook_url

Il tuo URL di callback viene validato al momento dell'invio. Se non supera la validazione, la richiesta di invio viene rifiutata con HTTP 400 e nessuna attività viene creata (non ti viene addebitato nulla).

RegolaDettaglio
SchemaDeve essere https://. Il semplice http:// viene rifiutato.
HostDeve essere un indirizzo pubblico e instradabile. Gli intervalli privati, di loopback, link-local e CGNAT (100.64.0.0/10) vengono rifiutati.
LunghezzaMassimo 1024 caratteri.
RaggiungibilitàDeve essere raggiungibile da internet pubblica affinché Atlas Cloud possa effettuare il POST.

Questi controlli sono una protezione SSRF. Per lo sviluppo locale, usa un tunnel pubblico (un servizio di test per webhook, ngrok o un tunnel Cloudflare) invece di un indirizzo privato.

La richiesta di callback

Quando l'attività raggiunge uno stato terminale, Atlas Cloud invia un POST con Content-Type: application/json e i seguenti header:

HeaderDescrizione
X-AtlasCloud-Webhook-IdIl session_id dell'attività — la tua chiave di correlazione e idempotenza.
X-AtlasCloud-Webhook-EventIl tipo di evento, ad es. video.task.terminal, image.task.terminal o audio.task.terminal.
X-AtlasCloud-Webhook-TimestampSecondi Unix epoch in cui è stato effettuato il tentativo di consegna. Coperto dalla firma Ed25519.
X-AtlasCloud-Webhook-SignatureHMAC-SHA256 codificato in esadecimale del corpo grezzo della richiesta (schema HMAC). Nello schema Ed25519 puro contiene invece la firma Ed25519 — vedi Verifica delle firme.
X-AtlasCloud-Webhook-Signature-Ed25519Firma Ed25519 in base64url di <timestamp>.<raw_body> (inviata durante la migrazione da HMAC a Ed25519).
X-AtlasCloud-Webhook-Key-IdIl kid della chiave di firma Ed25519 — corrisponde a una chiave nel JWKS.
User-AgentAtlasCloud-Webhook/1.0

Payload

{
  "session_id": "string",       // l'id dell'attività; la tua chiave di idempotenza
  "event_type": "string",       // ad es. "video.task.terminal"
  "status": "OK" | "ERROR",     // esito di primo livello — ramifica su questo
  "created_at": 1782295062952,  // ora di creazione dell'attività (ms epoch)
  "payload": {                  // il risultato, stessa struttura dell'API Predictions
    "model": "string",
    "status": "completed" | "failed" | "timeout",
    "outputs": ["https://..."], // presente in caso di successo
    "error_code": 0             // presente in caso di errore
  },
  "error": "string"             // presente solo quando status == "ERROR"
}

Ramifica il tuo handler sul campo status di primo livello: OK significa che in payload.outputs è presente un risultato utilizzabile; ERROR significa che l'attività non ha prodotto un risultato e error ne spiega il motivo.

Risultati di trascrizione audio

Le attività di sintesi vocale e le altre attività di audio generativo restituiscono gli URL dei file audio in payload.outputs, esattamente come video e immagini. Per i modelli di riconoscimento vocale (trascrizione), un payload completato contiene inoltre un oggetto strutturato stt_result (testo completo, lingua rilevata e timestamp a livello di parola) — lo stesso campo restituito dall'endpoint Predizioni.

Esempio di successo

Una consegna reale per un'attività bytedance/seedance-2.0/text-to-video completata:

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

Esempio di errore

{
  "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 esito timeout si presenta allo stesso modo, con payload.status: "timeout" e un messaggio error generico.

Verifica delle firme

Verifica sempre la firma prima di fidarti di un webhook. Dimostra che la richiesta proviene da Atlas Cloud e che non è stata manomessa.

Due schemi durante la migrazione

Atlas Cloud sta migrando le firme dei webhook da un segreto HMAC condiviso a Ed25519 con un endpoint JWKS pubblico. Durante la transizione, le consegne contengono entrambe le firme: una firma HMAC (X-AtlasCloud-Webhook-Signature) e una firma Ed25519 (X-AtlasCloud-Webhook-Signature-Ed25519). Preferisci Ed25519 — si verifica con una chiave pubblica che recuperi da un URL, senza alcun segreto condiviso da conservare.

Ed25519 + JWKS (consigliato)

Atlas Cloud firma ogni consegna con una chiave privata Ed25519 e pubblica la corrispondente chiave pubblica su un endpoint JWKS. La verifica avviene con quella chiave pubblica — non c'è alcun segreto da configurare o ruotare dalla tua parte.

  • Chiavi pubbliche (JWKS): GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json (non autenticato):
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
    }
  ]
}
  • Messaggio firmato: "<timestamp>.<raw_body>" — il valore di X-AtlasCloud-Webhook-Timestamp, un . letterale, poi il corpo grezzo esatto della richiesta. A differenza di HMAC, il timestamp è coperto dalla firma, quindi puoi applicare una finestra anti-replay.
  • Header della firma: X-AtlasCloud-Webhook-Signature-Ed25519 (base64url). Una volta ritirato HMAC, la firma Ed25519 passerà a X-AtlasCloud-Webhook-Signature — quindi preferisci l'header -Ed25519 quando è presente e ripiega su -Signature.
  • Id della chiave: X-AtlasCloud-Webhook-Key-Id è il kid del JWK che ha firmato la consegna.

Passaggi

  1. Leggi X-AtlasCloud-Webhook-Timestamp (ts), X-AtlasCloud-Webhook-Key-Id (kid) e l'header della firma Ed25519.
  2. (Consigliato) Rifiuta se il timestamp si discosta di più di ~5 minuti dal tuo orologio (protezione anti-replay).
  3. Recupera il JWKS e seleziona la chiave il cui kid corrisponde. Metti in cache il JWKS; in presenza di un kid che non riconosci, recuperalo di nuovo una volta (la chiave di firma potrebbe essere stata ruotata).
  4. Decodifica il campo x del JWK (base64url) → la chiave pubblica Ed25519 di 32 byte.
  5. Verifica la firma decodificata da base64url su 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 deve essere il Buffer del corpo GREZZO (ad es. 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; // finestra anti-replay

  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:  # finestra anti-replay
        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 restituisce la chiave pubblica base64url per kid. Nel codice reale
// aggiungi la cache + un singolo nuovo recupero in caso di miss (rotazione della chiave).
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 { // finestra anti-replay
        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 dimostra che il callback ha avuto origine da Atlas Cloud — non quale account possiede l'attività. Poiché webhook_url viene fornito per singola richiesta, correla anche il session_id a un'attività che hai effettivamente creato prima di agire sul risultato.

HMAC (legacy)

La firma HMAC è in via di deprecazione a favore di Ed25519/JWKS descritto sopra; le nuove integrazioni dovrebbero usare Ed25519. L'header HMAC X-AtlasCloud-Webhook-Signature continua a essere inviato durante la finestra di migrazione.

La firma viene calcolata come:

HMAC-SHA256( signing_secret, raw_request_body )   →   lowercase hex
  • La chiave è il tuo segreto di firma condiviso (fornito per il tuo account).
  • Il messaggio è costituito dai byte grezzi esatti del corpo della richiesta — verifica prima di qualsiasi ri-serializzazione JSON, che potrebbe alterare l'ordine dei byte o gli spazi bianchi.
  • Confronta il risultato con l'header X-AtlasCloud-Webhook-Signature usando un confronto a tempo costante.

L'header X-AtlasCloud-Webhook-Timestamp è informativo (utile per un controllo opzionale della finestra anti-replay). Non fa parte del contenuto firmato — viene firmato solo il corpo grezzo.

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

const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;

// Cattura il corpo GREZZO — non lasciare che un parser JSON venga eseguito prima.
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 è un Buffer (byte grezzi)
    .digest("hex");

  // Confronta come Buffer e controlla la lunghezza in BYTE: timingSafeEqual solleva
  // un errore su input di lunghezza diversa, e un header multibyte malformato può
  // coincidere nella lunghezza della stringa JS pur differendo in lunghezza in byte.
  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"));
  // ... accoda per event.session_id, poi rispondi rapidamente ...
  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()  # byte grezzi, prima del parsing JSON
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
    # Confronta come byte: compare_digest su str solleva un errore con input non ASCII.
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    # ... accoda per event["session_id"], poi rispondi 200 rapidamente ...
    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))
}

Semantica di consegna

Confermare una consegna

Rispondi con un qualsiasi codice di stato 2xx per confermare la ricezione. Qualsiasi altro codice di stato — o un timeout di connessione — viene trattato come un errore e la consegna viene ritentata.

Rispondi rapidamente (ben entro pochi secondi). Svolgi il lavoro effettivo in modo asincrono: verifica la firma, accoda l'evento indicizzato per session_id e restituisci subito 200. Risposte lente rischiano di andare in timeout e di innescare nuovi tentativi inutili.

Nuovi tentativi e backoff

Se una consegna non viene confermata, Atlas Cloud ritenta con backoff esponenziale (all'incirca 10s → 20s → 40s → …, con un limite massimo di circa 30 minuti), per un massimo di ~10 tentativi. Una volta esauriti i tentativi, la consegna viene contrassegnata come non recapitabile e non viene più ritentata.

At-least-once — deduplica su session_id

La consegna è at-least-once (almeno una volta). In rari casi potresti ricevere lo stesso webhook più di una volta. Rendi il tuo handler idempotente e deduplica su session_id.

Il session_id rimane stabile tra un tentativo e l'altro. Tratta un webhook con un session_id che hai già elaborato completamente come un'operazione nulla e restituisci 200.

Tempestività

La stragrande maggioranza dei webhook viene consegnata entro pochi secondi dal termine dell'attività. Una rete di sicurezza di riconciliazione integrata garantisce la consegna anche quando il percorso rapido viene mancato (ad esempio durante il deploy di un servizio), al costo di un ritardo fino a ~30 minuti in questi casi non comuni. Progetta per una consegna eventuale e at-least-once, non istantanea ed esattamente una volta.

Buone pratiche

  • Esponi il callback su HTTPS su un host pubblicamente raggiungibile.
  • Verifica la firma del webhook prima di fidarti dell'evento — preferibilmente con Ed25519/JWKS (nessun segreto da conservare; verifica con la chiave pubblica recuperata tramite kid). HMAC è il ripiego legacy durante la finestra di migrazione.
  • Quando verifichi con Ed25519, metti in cache il JWKS e recuperalo di nuovo in presenza di un kid sconosciuto; verifica la firma su "<timestamp>.<raw_body>" e applica una finestra anti-replay.
  • Rispondi 2xx rapidamente; sposta l'elaborazione reale su una coda in background.
  • Deduplica su session_id — gli handler devono essere idempotenti.
  • Ramifica sullo status di primo livello (OK vs ERROR); leggi i risultati da payload.outputs.
  • Non dare per scontato l'ordine o la consegna esattamente una volta; progetta per at-least-once.
  • Mantieni l'endpoint Predizioni come percorso di ripiego / riconciliazione.
  • Solo per HMAC legacy: mantieni riservato il tuo segreto di firma e ruotalo se viene esposto. (Con Ed25519/JWKS non c'è alcun segreto dalla tua parte.)

Risoluzione dei problemi

SintomoCausa probabile / azione
L'invio restituisce 400 webhook_url ... is not a routable public addressL'host è privato/loopback/CGNAT. Usa un URL HTTPS pubblico o un tunnel.
L'invio restituisce 400 webhook_url must use httpsCambia lo schema in https://.
L'invio restituisce 400 webhook_url exceeds the 1024-character limitAccorcia l'URL (sposta lo stato nel tuo archivio, indicizzato per session_id).
Nessun webhook ricevutoVerifica che l'endpoint sia pubblicamente raggiungibile e restituisca 2xx; controlla tramite l'endpoint Predizioni che l'attività abbia effettivamente raggiunto uno stato terminale.
Firma non corrispondente (Ed25519)Firma su "<timestamp>.<raw_body>" (non solo sul corpo), decodifica la firma da base64url e cerca la chiave tramite il kid nel JWKS.
Firma non corrispondente (HMAC)Assicurati di calcolare l'HMAC sui byte grezzi del corpo (non su un oggetto JSON ri-serializzato) e di usare il segreto di firma corretto.
kid non presente nel JWKSLa chiave di firma è stata ruotata — recupera di nuovo il JWKS (non mettere in cache una singola chiave per sempre).
Stesso evento ricevuto due volteÈ previsto con la consegna at-least-once — deduplica su session_id.

Riferimento

  • Invio (con webhook): POST /api/v1/model/generateVideo, POST /api/v1/model/generateImage o POST /api/v1/model/generateAudio — aggiungi webhook_url.
  • Tipi di evento: video.task.terminal, image.task.terminal, audio.task.terminal.
  • JWKS (chiavi pubbliche): GET /api/v1/webhooks/jwks.json.
  • Firma (Ed25519, consigliata): Ed25519 in base64url su "<timestamp>.<raw_body>", nell'header X-AtlasCloud-Webhook-Signature-Ed25519 (id della chiave in X-AtlasCloud-Webhook-Key-Id).
  • Firma (HMAC, legacy): HMAC-SHA256(signing_secret, raw_body), esadecimale, nell'header X-AtlasCloud-Webhook-Signature.
  • Chiave di idempotenza: session_id (presente anche in X-AtlasCloud-Webhook-Id).
  • Alternativa con polling: Predizioni.