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. |
X-AtlasCloud-Webhook-Signature | Hex-encoded HMAC-SHA256 of the raw request body (see Verifying signatures). |
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.
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 HMAC signature over the raw body before trusting the event.
- 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.
- Keep your signing secret confidential; rotate it if exposed.
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 | Ensure you HMAC the raw body bytes (not a re-serialized JSON object) and use the correct signing secret. |
| 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. - Signature:
HMAC-SHA256(signing_secret, raw_body), hex, inX-AtlasCloud-Webhook-Signature. - Idempotency key:
session_id(also inX-AtlasCloud-Webhook-Id). - Polling alternative: Predictions.