Webhooks

Get notified the moment an async generation task finishes — instead of polling

Overview

When you submit an async generation task, Atlas Cloud processes it in the background and the result becomes available some time later. Instead of repeatedly calling the Predictions endpoint until the task finishes, you can ask Atlas Cloud to call you back the moment the task reaches a terminal state.

You do this by supplying a webhook_url when you submit the task. When the task finishes — whether it succeeded, failed, or timed out — Atlas Cloud sends a single signed POST to that URL containing the final result.

Supported task types

Webhooks are available for async video and image generation. The delivery engine is task-type agnostic — the event_type (and the X-AtlasCloud-Webhook-Event header) identifies the modality: video.task.terminal or image.task.terminal.

Webhooks complement polling — they don't replace it. The Predictions endpoint keeps working exactly as before, and a webhook payload carries the same result shape you would have polled for. Use either, or both.

Quick start

Add a webhook_url field to your existing submit request:

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"])  # the task id (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); // the task id (session_id)

The same webhook_url field works identically on POST /api/v1/model/generateImage for async image tasks (the callback then carries event_type: "image.task.terminal").

The submit response is unchanged — you still get a task id (the session_id) back immediately. The webhook_url is consumed by Atlas Cloud and never forwarded to the upstream model provider. When the task completes, your endpoint receives a POST.

Requirements for webhook_url

Your callback URL is validated at submit time. If it fails validation, the submit request is rejected with HTTP 400 and no task is created (you are not charged).

RuleDetail
SchemeMust be https://. Plain http:// is rejected.
HostMust be a routable, public address. Private, loopback, link-local, and CGNAT (100.64.0.0/10) ranges are rejected.
LengthMaximum 1024 characters.
ReachabilityMust be reachable from the public internet so Atlas Cloud can POST to it.

These checks are an SSRF protection. For local development, use a public tunnel (a webhook-testing service, ngrok, or a Cloudflare tunnel) rather than a private address.

The callback request

When the task reaches a terminal state, Atlas Cloud sends a POST with Content-Type: application/json and the following headers:

HeaderDescription
X-AtlasCloud-Webhook-IdThe task session_id — your correlation & idempotency key.
X-AtlasCloud-Webhook-EventThe event type, e.g. video.task.terminal or image.task.terminal.
X-AtlasCloud-Webhook-TimestampUnix epoch seconds when the delivery attempt was made. Covered by the Ed25519 signature.
X-AtlasCloud-Webhook-SignatureHex-encoded HMAC-SHA256 of the raw request body (HMAC scheme). In the pure Ed25519 scheme this carries the Ed25519 signature instead — see Verifying signatures.
X-AtlasCloud-Webhook-Signature-Ed25519base64url Ed25519 signature of <timestamp>.<raw_body> (sent during the HMAC→Ed25519 migration).
X-AtlasCloud-Webhook-Key-IdThe kid of the Ed25519 signing key — matches a key in the JWKS.
User-AgentAtlasCloud-Webhook/1.0

Payload

{
  "session_id": "string",       // the task id; your idempotency key
  "event_type": "string",       // e.g. "video.task.terminal"
  "status": "OK" | "ERROR",     // top-level outcome — branch on this
  "created_at": 1782295062952,  // task creation time (ms epoch)
  "payload": {                  // the result, same shape as the Predictions API
    "model": "string",
    "status": "completed" | "failed" | "timeout",
    "outputs": ["https://..."], // present on success
    "error_code": 0             // present on failure
  },
  "error": "string"             // present only when status == "ERROR"
}

Branch your handler on the top-level status field: OK means a usable result is in payload.outputs; ERROR means the task did not produce a result and error explains why.

Success example

A real delivery for a completed bytedance/seedance-2.0/text-to-video task:

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

Failure example

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

A timeout outcome looks the same with payload.status: "timeout" and a generic error message.

Verifying signatures

Always verify the signature before trusting a webhook. It proves the request came from Atlas Cloud and was not tampered with.

Two schemes during migration

Atlas Cloud is moving webhook signatures from a shared HMAC secret to Ed25519 with a public JWKS endpoint. During the transition, deliveries carry both an HMAC signature (X-AtlasCloud-Webhook-Signature) and an Ed25519 signature (X-AtlasCloud-Webhook-Signature-Ed25519). Prefer Ed25519 — it verifies against a public key you fetch from a URL, with no shared secret to store.

Atlas Cloud signs each delivery with an Ed25519 private key and publishes the matching public key at a JWKS endpoint. You verify against that public key — there is no secret to provision or rotate on your side.

  • Public keys (JWKS): GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json (unauthenticated):
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
    }
  ]
}
  • Signed message: "<timestamp>.<raw_body>" — the X-AtlasCloud-Webhook-Timestamp value, a literal ., then the exact raw request body. Unlike HMAC, the timestamp is covered by the signature, so you can enforce a replay window.
  • Signature header: X-AtlasCloud-Webhook-Signature-Ed25519 (base64url). Once HMAC is retired the Ed25519 signature moves to X-AtlasCloud-Webhook-Signature — so prefer the -Ed25519 header when present and fall back to -Signature.
  • Key id: X-AtlasCloud-Webhook-Key-Id is the kid of the JWK that signed the delivery.

Steps

  1. Read the X-AtlasCloud-Webhook-Timestamp (ts), X-AtlasCloud-Webhook-Key-Id (kid), and the Ed25519 signature header.
  2. (Recommended) Reject if the timestamp is more than ~5 minutes from your clock (replay protection).
  3. Fetch the JWKS and select the key whose kid matches. Cache the JWKS; on a kid you don't recognize, re-fetch once (the signing key may have rotated).
  4. Decode the JWK x (base64url) → the 32-byte Ed25519 public key.
  5. Verify the base64url-decoded signature over 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 must be the RAW body Buffer (e.g. 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; // replay window

  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:  # replay window
        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 returns the base64url public key for kid. Add caching + a single
// refetch-on-miss (key rotation) in real code.
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 { // replay window
        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)
}

The signature proves the callback originated from Atlas Cloud — not which account owns the task. Because webhook_url is supplied per request, also correlate the session_id to a task you actually created before acting on the result.

HMAC (legacy)

HMAC signing is being deprecated in favor of Ed25519/JWKS above; new integrations should use Ed25519. The HMAC X-AtlasCloud-Webhook-Signature continues to be sent during the migration window.

The signature is computed as:

HMAC-SHA256( signing_secret, raw_request_body )   →   lowercase hex
  • The key is your shared signing secret (provisioned for your account).
  • The message is the exact raw bytes of the request body — verify before any JSON re-serialization, which could change byte order or whitespace.
  • Compare the result to the X-AtlasCloud-Webhook-Signature header using a constant-time comparison.

The X-AtlasCloud-Webhook-Timestamp header is informational (useful for an optional replay-window check). It is not part of the signed content — only the raw body is signed.

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

const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;

// Capture the RAW body — do not let a JSON parser run first.
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 is a Buffer (raw bytes)
    .digest("hex");

  // Compare as Buffers and guard on BYTE length: timingSafeEqual throws on
  // unequal-length inputs, and a malformed multibyte header can match in JS
  // string length while differing in byte length.
  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"));
  // ... enqueue by event.session_id, then respond fast ...
  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()  # raw bytes, before JSON parsing
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
    # Compare as bytes: compare_digest on str raises on non-ASCII input.
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    # ... enqueue by event["session_id"], then respond 200 quickly ...
    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))
}

Delivery semantics

Acknowledging a delivery

Respond with any 2xx status code to acknowledge receipt. Any other status code — or a connection timeout — is treated as a failure and the delivery is retried.

Respond quickly (well within a few seconds). Do your real work asynchronously: verify the signature, enqueue the event keyed by session_id, and return 200 immediately. Slow responses risk timing out and triggering unnecessary retries.

Retries and backoff

If a delivery is not acknowledged, Atlas Cloud retries with exponential backoff (roughly 10s → 20s → 40s → …, capped at about 30 minutes), for up to ~10 attempts. After the attempts are exhausted the delivery is marked undeliverable and is no longer retried.

At-least-once — deduplicate on session_id

Delivery is at-least-once. In rare cases you may receive the same webhook more than once. Make your handler idempotent and deduplicate on session_id.

The session_id is stable across retries. Treat a webhook for a session_id you have already fully processed as a no-op and return 200.

Timeliness

The vast majority of webhooks are delivered within seconds of the task finishing. A built-in reconciliation safety-net guarantees delivery even if the fast path is missed (for example during a service deployment), at the cost of a delay of up to ~30 minutes in those uncommon cases. Build for eventual, at-least-once delivery rather than instantaneous, exactly-once.

Best practices

  • Serve the callback over HTTPS on a publicly reachable host.
  • Verify the webhook signature before trusting the event — preferably Ed25519/JWKS (no secret to store; verify against the public key fetched by kid). HMAC is the legacy fallback during the migration window.
  • When verifying Ed25519, cache the JWKS and re-fetch on an unknown kid; sign-check over "<timestamp>.<raw_body>" and enforce a replay window.
  • Respond 2xx fast; move real processing to a background queue.
  • Deduplicate on session_id — handlers must be idempotent.
  • Branch on the top-level status (OK vs ERROR); read results from payload.outputs.
  • Don't assume ordering or exactly-once; design for at-least-once.
  • Keep the Predictions endpoint as a fallback / reconciliation path.
  • Legacy HMAC only: keep your signing secret confidential and rotate it if exposed. (Ed25519/JWKS has no secret on your side.)

Troubleshooting

SymptomLikely cause / action
Submit returns 400 webhook_url ... is not a routable public addressThe host is private/loopback/CGNAT. Use a public HTTPS URL or a tunnel.
Submit returns 400 webhook_url must use httpsSwitch the scheme to https://.
Submit returns 400 webhook_url exceeds the 1024-character limitShorten the URL (move state into your own store keyed by session_id).
No webhook receivedConfirm the endpoint is publicly reachable and returns 2xx; verify the task actually reached a terminal state via the Predictions endpoint.
Signature mismatch (Ed25519)Sign over "<timestamp>.<raw_body>" (not the body alone), base64url-decode the signature, and look up the key by kid in the JWKS.
Signature mismatch (HMAC)Ensure you HMAC the raw body bytes (not a re-serialized JSON object) and use the correct signing secret.
kid not in the JWKSThe signing key rotated — re-fetch the JWKS (don't cache a single key forever).
Received the same event twiceExpected under at-least-once delivery — deduplicate on session_id.

Reference

  • Submit (with webhook): POST /api/v1/model/generateVideo or POST /api/v1/model/generateImage — add webhook_url.
  • Event types: video.task.terminal, image.task.terminal.
  • JWKS (public keys): GET /api/v1/webhooks/jwks.json.
  • Signature (Ed25519, recommended): base64url Ed25519 over "<timestamp>.<raw_body>", in X-AtlasCloud-Webhook-Signature-Ed25519 (key id in X-AtlasCloud-Webhook-Key-Id).
  • Signature (HMAC, legacy): HMAC-SHA256(signing_secret, raw_body), hex, in X-AtlasCloud-Webhook-Signature.
  • Idempotency key: session_id (also in X-AtlasCloud-Webhook-Id).
  • Polling alternative: Predictions.