Webhook

非同期生成タスクが完了した瞬間に通知を受け取る — ポーリングは不要

概要

非同期生成タスクを送信すると、Atlas Cloud はバックグラウンドで処理を行い、結果はしばらく経ってから利用可能になります。タスクが完了するまで 予測 エンドポイントを繰り返し呼び出す代わりに、タスクが終了状態に達した瞬間に Atlas Cloud から コールバックしてもらう ことができます。

これはタスクの送信時に webhook_url を指定するだけで有効になります。タスクが完了すると — 成功失敗タイムアウト のいずれであっても — Atlas Cloud はその URL に、最終結果を含む署名済みの POST を 1 回だけ送信します。

対応するタスクタイプ

Webhook は非同期の 動画画像音声 生成で利用できます。配信エンジンはタスクタイプに依存せず、event_type(および X-AtlasCloud-Webhook-Event ヘッダー)がモダリティを示します: video.task.terminalimage.task.terminalaudio.task.terminal

Webhook はポーリングを 補完 するものであり、置き換えるものではありません。予測 エンドポイントはこれまでとまったく同じように動作し、Webhook のペイロードにはポーリングで取得できるものと同じ形式の結果が含まれます。どちらか一方でも、両方でも利用できます。

クイックスタート

既存の送信リクエストに webhook_url フィールドを追加します:

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"])  # タスク 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); // タスク ID(session_id)

同じ webhook_url フィールドは、POST /api/v1/model/generateImage(非同期の画像生成、event_type: "image.task.terminal")と POST /api/v1/model/generateAudio(非同期の音声生成、event_type: "audio.task.terminal")でもまったく同じように機能します。

送信レスポンスは変わりません — これまでどおり、タスクの idsession_id)がすぐに返されます。webhook_urlAtlas Cloud 内で消費され、上流のモデルプロバイダーに転送されることは決してありません。タスクが完了すると、指定したエンドポイントが POST を受け取ります。

webhook_url の要件

コールバック URL は送信時に検証されます。検証に失敗した場合、送信リクエストは HTTP 400 で拒否され、タスクは作成されません(課金も発生しません)。

ルール詳細
スキームhttps:// である必要があります。平文の http:// は拒否されます。
ホストルーティング可能なパブリックアドレスである必要があります。プライベート、ループバック、リンクローカル、CGNAT(100.64.0.0/10)の範囲は拒否されます。
長さ最大 1024 文字。
到達可能性Atlas Cloud が POST できるよう、パブリックインターネットから到達可能である必要があります。

これらのチェックは SSRF 対策です。ローカル開発では、プライベートアドレスではなく公開トンネル(Webhook テスト用サービス、ngrok、Cloudflare トンネルなど)を使用してください。

コールバックリクエスト

タスクが終了状態に達すると、Atlas Cloud は Content-Type: application/json と以下のヘッダーを付けて POST を送信します:

ヘッダー説明
X-AtlasCloud-Webhook-Idタスクの session_id — 突き合わせと冪等性のためのキーです。
X-AtlasCloud-Webhook-Eventイベントタイプ。例: video.task.terminalimage.task.terminalaudio.task.terminal
X-AtlasCloud-Webhook-Timestamp配信を試みた時刻の Unix エポック 。Ed25519 署名の対象に含まれます。
X-AtlasCloud-Webhook-Signature生のリクエストボディHMAC-SHA256(16 進エンコード、HMAC 方式)。純粋な Ed25519 方式では、代わりに Ed25519 署名が入ります — 署名の検証 を参照してください。
X-AtlasCloud-Webhook-Signature-Ed25519<timestamp>.<raw_body> の base64url Ed25519 署名(HMAC → Ed25519 の移行期間中に送信されます)。
X-AtlasCloud-Webhook-Key-IdEd25519 署名鍵の kidJWKS 内の鍵と一致します。
User-AgentAtlasCloud-Webhook/1.0

ペイロード

{
  "session_id": "string",       // タスク ID。冪等性キーとして使います
  "event_type": "string",       // 例: "video.task.terminal"
  "status": "OK" | "ERROR",     // トップレベルの結果 — ここで分岐します
  "created_at": 1782295062952,  // タスク作成時刻(エポックミリ秒)
  "payload": {                  // 結果。Predictions API と同じ形式です
    "model": "string",
    "status": "completed" | "failed" | "timeout",
    "outputs": ["https://..."], // 成功時に存在します
    "error_code": 0             // 失敗時に存在します
  },
  "error": "string"             // status == "ERROR" の場合のみ存在します
}

ハンドラーではトップレベルの status フィールドで分岐してください: OKpayload.outputs に利用可能な結果があることを意味し、ERROR はタスクが結果を生成しなかったことを意味して、error にその理由が示されます。

音声文字起こしの結果

テキスト読み上げなどの生成系の音声タスクは、動画や画像とまったく同じように payload.outputs に音声ファイルの URL を返します。音声認識(文字起こし) モデルの場合、完了した payload にはさらに構造化された stt_result オブジェクト(全文、検出された言語、単語単位のタイムスタンプ)が含まれます — 予測 エンドポイントが返すものと同じフィールドです。

成功例

完了した bytedance/seedance-2.0/text-to-video タスクの実際の配信例:

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

失敗例

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

timeout の場合も同じ形式で、payload.status: "timeout" と一般的な error メッセージが返されます。

署名の検証

Webhook を信頼する前に、必ず署名を検証してください。署名は、そのリクエストが Atlas Cloud から送信され、改ざんされていないことを証明します。

移行期間中の 2 つの方式

Atlas Cloud は Webhook の署名を、共有 HMAC シークレットから 公開 JWKS エンドポイントを利用した Ed25519 へ移行しています。移行期間中、配信には HMAC 署名(X-AtlasCloud-Webhook-Signature)と Ed25519 署名(X-AtlasCloud-Webhook-Signature-Ed25519)の 両方 が含まれます。Ed25519 の使用を推奨します — URL から取得した公開鍵で検証でき、保管すべき共有シークレットがありません。

Ed25519 + JWKS(推奨)

Atlas Cloud は各配信を Ed25519 の秘密鍵で署名し、対応する 公開 鍵を JWKS エンドポイントで公開しています。この公開鍵で検証するため、利用者側でシークレットを用意したりローテーションしたりする必要はありません。

  • 公開鍵(JWKS): GET https://api.atlascloud.ai/api/v1/webhooks/jwks.json(認証不要):
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "x": "Wn_rgZDBO6nv4-ka97PPf5z8WXbM25o75dR6YukTqqI",
      "use": "sig",
      "alg": "EdDSA",
      "kid": "cnut8IN2blKoB5zRJr9th0HoLzH-iBH3WYoUtkcJZQE"
    }
  ]
}
  • 署名対象のメッセージ: "<timestamp>.<raw_body>"X-AtlasCloud-Webhook-Timestamp の値、リテラルの .、そして リクエストボディの正確な生バイト列 です。HMAC とは異なり、タイムスタンプが署名の対象に 含まれる ため、リプレイウィンドウを適用できます。
  • 署名ヘッダー: X-AtlasCloud-Webhook-Signature-Ed25519(base64url)。HMAC が廃止された後、Ed25519 署名は X-AtlasCloud-Webhook-Signature に移ります — したがって -Ed25519 ヘッダーがあればそれを優先し、なければ -Signature にフォールバックしてください。
  • 鍵 ID: X-AtlasCloud-Webhook-Key-Id は、その配信に署名した JWK の kid です。

手順

  1. X-AtlasCloud-Webhook-Timestamp(ts)、X-AtlasCloud-Webhook-Key-Id(kid)、および Ed25519 署名ヘッダーを読み取ります。
  2. (推奨)タイムスタンプが自分の時計から約 5 分以上ずれている場合は拒否します(リプレイ対策)。
  3. JWKS を取得し、kid が一致する鍵を選びます。JWKS は キャッシュ し、認識できない kid を受け取った場合は 1 度だけ再取得 してください(署名鍵がローテーションされた可能性があります)。
  4. JWK の x(base64url)をデコードして、32 バイトの Ed25519 公開鍵を取得します。
  5. ts + "." + raw_body に対して、base64url デコードした署名を検証します。
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 は生のボディ Buffer である必要があります(例: 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; // リプレイウィンドウ

  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:  # リプレイウィンドウ
        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 は kid に対応する base64url の公開鍵を返します。実際のコードでは
// キャッシュと、ミス時の 1 回だけの再取得(鍵のローテーション対応)を追加してください。
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 { // リプレイウィンドウ
        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)
}

署名が証明するのは、コールバックが Atlas Cloud から発信された ことであり、どのアカウントがそのタスクを所有しているかではありません。webhook_url はリクエストごとに指定されるため、結果を処理する前に、session_id が実際に自分が作成したタスクのものかどうかも突き合わせてください。

HMAC(レガシー)

HMAC 署名は、上記の Ed25519/JWKS を優先する形で非推奨になりつつあります。新規の統合では Ed25519 を使用してください。移行期間中は、HMAC の X-AtlasCloud-Webhook-Signature も引き続き送信されます。

署名は次のように計算されます:

HMAC-SHA256( signing_secret, raw_request_body )   →   lowercase hex
  • 鍵は 共有の署名シークレット(アカウントごとに発行されます)です。
  • メッセージは リクエストボディの正確な生バイト列 です — JSON を再シリアライズするとバイト順や空白が変わる可能性があるため、再シリアライズの に検証してください。
  • 計算結果は X-AtlasCloud-Webhook-Signature ヘッダーと 定数時間 で比較してください。

X-AtlasCloud-Webhook-Timestamp ヘッダーは参考情報です(任意のリプレイウィンドウのチェックに利用できます)。署名対象には 含まれません — 署名されるのは生のボディのみです。

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

const SIGNING_SECRET = process.env.ATLASCLOUD_WEBHOOK_SECRET;

// 生のボディを取得します — JSON パーサーを先に実行させないでください。
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 は Buffer(生バイト列)です
    .digest("hex");

  // Buffer として比較し、バイト長でガードします: timingSafeEqual は長さが異なる
  // 入力で例外を投げ、不正なマルチバイトのヘッダーは JS の文字列長では一致しても
  // バイト長では異なることがあります。
  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"));
  // ... event.session_id をキーにキューへ入れ、すばやく応答します ...
  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()  # JSON パース前の生バイト列
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    received = request.headers.get("X-AtlasCloud-Webhook-Signature", "")
    # バイト列として比較します: compare_digest は非 ASCII の str で例外を投げます。
    if not hmac.compare_digest(expected.encode(), received.encode()):
        abort(401)

    event = request.get_json()
    # ... event["session_id"] をキーにキューへ入れ、すばやく 200 を返します ...
    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))
}

配信のセマンティクス

配信の確認応答

受信を確認するには、任意の 2xx ステータスコードを返してください。それ以外のステータスコード、または接続タイムアウトは失敗と見なされ、配信は 再送 されます。

すばやく 応答してください(数秒以内に十分収まるように)。実際の処理は非同期で行います: 署名を検証し、session_id をキーにしてイベントをキューへ入れ、すぐに 200 を返します。応答が遅いとタイムアウトし、不要な再送を招く恐れがあります。

再送とバックオフ

配信が確認応答されない場合、Atlas Cloud は 指数バックオフ(おおよそ 10s → 20s → 40s → …、上限は約 30 分)で最大 約 10 回 まで再送します。試行回数を使い切ると、その配信は配信不能としてマークされ、以降は再送されません。

at-least-once — session_id で重複排除する

配信は at-least-once(少なくとも 1 回) です。まれに同じ Webhook を複数回受け取ることがあります。ハンドラーを冪等にし、session_id で重複排除 してください。

session_id は再送をまたいで変わりません。すでに完全に処理済みの session_id に対する Webhook は何もせず、200 を返してください。

適時性

大半の Webhook は、タスク完了から数秒以内に配信されます。組み込みの整合性チェックによるセーフティネットにより、高速パスを取り逃した場合(サービスのデプロイ中など)でも配信は保証されますが、その稀なケースでは最大 30 分程度の遅延が生じます。瞬時かつ exactly-once ではなく、最終的にat-least-once で配信されるものとして設計してください。

ベストプラクティス

  • パブリックに到達可能な ホスト上で、コールバックを HTTPS で受け付ける。
  • イベントを信頼する前に Webhook の署名を検証する — できれば Ed25519/JWKS を使う(保管すべきシークレットがなく、kid で取得した公開鍵で検証できます)。HMAC は移行期間中のレガシーなフォールバックです。
  • Ed25519 で検証する際は JWKS をキャッシュ し、未知の kid では再取得する。"<timestamp>.<raw_body>" に対して署名を検証し、リプレイウィンドウを適用する。
  • 2xx をすばやく返す。実際の処理はバックグラウンドのキューに移す。
  • session_id で重複排除する — ハンドラーは冪等でなければなりません。
  • トップレベルの status で分岐するOKERROR)。結果は payload.outputs から読み取る。
  • 順序や exactly-once を前提にしない。at-least-once を前提に設計する。
  • 予測 エンドポイントをフォールバック / 整合性チェックの経路として残しておく。
  • レガシーな HMAC を使う場合のみ: 署名シークレットを機密として扱い、漏洩した場合はローテーションする。(Ed25519/JWKS では利用者側にシークレットはありません。)

トラブルシューティング

症状考えられる原因 / 対処
送信時に 400 webhook_url ... is not a routable public address が返るホストがプライベート / ループバック / CGNAT です。パブリックな HTTPS URL かトンネルを使用してください。
送信時に 400 webhook_url must use https が返るスキームを https:// に変更してください。
送信時に 400 webhook_url exceeds the 1024-character limit が返るURL を短くしてください(状態は session_id をキーに自前のストアへ移します)。
Webhook が届かないエンドポイントがパブリックに到達可能で 2xx を返すことを確認し、予測 エンドポイントでタスクが実際に終了状態に達したかを確認してください。
署名が一致しない(Ed25519)ボディ単体ではなく "<timestamp>.<raw_body>" に対して署名を検証し、署名を base64url デコードして、JWKS 内で kid から鍵を引いてください。
署名が一致しない(HMAC)再シリアライズした JSON オブジェクトではなく 生の ボディのバイト列を HMAC にかけ、正しい署名シークレットを使用してください。
kid が JWKS にない署名鍵がローテーションされました — JWKS を再取得してください(単一の鍵を永久にキャッシュしないでください)。
同じイベントを 2 回受け取ったat-least-once 配信では想定内です — session_id で重複排除してください。

リファレンス

  • 送信(Webhook 付き): POST /api/v1/model/generateVideoPOST /api/v1/model/generateImagePOST /api/v1/model/generateAudiowebhook_url を追加します。
  • イベントタイプ: video.task.terminalimage.task.terminalaudio.task.terminal
  • JWKS(公開鍵): GET /api/v1/webhooks/jwks.json
  • 署名(Ed25519、推奨): "<timestamp>.<raw_body>" に対する base64url の Ed25519 署名。X-AtlasCloud-Webhook-Signature-Ed25519 に格納されます(鍵 ID は X-AtlasCloud-Webhook-Key-Id)。
  • 署名(HMAC、レガシー): HMAC-SHA256(signing_secret, raw_body) の 16 進表現。X-AtlasCloud-Webhook-Signature に格納されます。
  • 冪等性キー: session_idX-AtlasCloud-Webhook-Id にも含まれます)。
  • ポーリングによる代替手段: 予測