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).
| Rule | Detail |
|---|---|
| Scheme | Must be https://. Plain http:// is rejected. |
| Host | Must be a routable, public address. Private, loopback, link-local, and CGNAT (100.64.0.0/10) ranges are rejected. |
| Length | Maximum 1024 characters. |
| Reachability | Must 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:
| Header | Description |
|---|---|
X-AtlasCloud-Webhook-Id | The task session_id — your correlation & idempotency key. |
X-AtlasCloud-Webhook-Event | The event type, e.g. video.task.terminal or image.task.terminal. |
X-AtlasCloud-Webhook-Timestamp | Unix epoch seconds when the delivery attempt was made. Covered by the Ed25519 signature. |
X-AtlasCloud-Webhook-Signature | Hex-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-Ed25519 | base64url Ed25519 signature of <timestamp>.<raw_body> (sent during the HMAC→Ed25519 migration). |
X-AtlasCloud-Webhook-Key-Id | The kid of the Ed25519 signing key — matches a key in the JWKS. |
User-Agent | AtlasCloud-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.
Ed25519 + JWKS (recommended)
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>"— theX-AtlasCloud-Webhook-Timestampvalue, 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 toX-AtlasCloud-Webhook-Signature— so prefer the-Ed25519header when present and fall back to-Signature. - Key id:
X-AtlasCloud-Webhook-Key-Idis thekidof the JWK that signed the delivery.
Steps
- Read the
X-AtlasCloud-Webhook-Timestamp(ts),X-AtlasCloud-Webhook-Key-Id(kid), and the Ed25519 signature header. - (Recommended) Reject if the timestamp is more than ~5 minutes from your clock (replay protection).
- Fetch the JWKS and select the key whose
kidmatches. Cache the JWKS; on akidyou don't recognize, re-fetch once (the signing key may have rotated). - Decode the JWK
x(base64url) → the 32-byte Ed25519 public key. - 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 Falseconst 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-Signatureheader 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", 200func 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
2xxfast; move real processing to a background queue. - Deduplicate on
session_id— handlers must be idempotent. - Branch on the top-level
status(OKvsERROR); read results frompayload.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
| Symptom | Likely cause / action |
|---|---|
Submit returns 400 webhook_url ... is not a routable public address | The host is private/loopback/CGNAT. Use a public HTTPS URL or a tunnel. |
Submit returns 400 webhook_url must use https | Switch the scheme to https://. |
Submit returns 400 webhook_url exceeds the 1024-character limit | Shorten the URL (move state into your own store keyed by session_id). |
| No webhook received | Confirm 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 JWKS | The signing key rotated — re-fetch the JWKS (don't cache a single key forever). |
| Received the same event twice | Expected under at-least-once delivery — deduplicate on session_id. |
Reference
- Submit (with webhook):
POST /api/v1/model/generateVideoorPOST /api/v1/model/generateImage— addwebhook_url. - Event types:
video.task.terminal,image.task.terminal. - JWKS (public keys):
GET /api/v1/webhooks/jwks.json. - Signature (Ed25519, recommended): base64url
Ed25519over"<timestamp>.<raw_body>", inX-AtlasCloud-Webhook-Signature-Ed25519(key id inX-AtlasCloud-Webhook-Key-Id). - Signature (HMAC, legacy):
HMAC-SHA256(signing_secret, raw_body), hex, inX-AtlasCloud-Webhook-Signature. - Idempotency key:
session_id(also inX-AtlasCloud-Webhook-Id). - Polling alternative: Predictions.