ガイド
Webhook
generation.succeededとgeneration.failedのWebhookを受信し、Node.jsまたはPythonでCheaperGen-SignatureのHMAC-SHA256ヘッダーを検証します。
ポーリングの代わりに、生成の送信時にwebhook_urlを指定します。ジョブが最終状態になると、生成オブジェクト全体を含むHTTPS POSTを指定URLに送信し、アカウントのシークレットで署名します。
Webhookを指定する#
任意のPOST /api/v1/run/{endpoint}リクエスト本文にwebhook_urlを指定します。インターネットからアクセスできるHTTPS URLが必要です。
curl -X POST https://cheapergen.com/api/v1/run/bytedance/seedance-2.0-fast/text-to-video \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "ゴールデンアワーの光の中、陶芸家が花瓶を形作る様子をゆっくり寄って撮影。光に浮かぶほこり、浅い被写界深度。",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"webhook_url": "https://your-app.com/hooks/cheapergen"
}'イベント#
generation.succeededevent- 生成が完了しました。
data.outputにメディアURL、data.costに最終料金が含まれます。 generation.failedevent- 生成に失敗しました。
data.errorが原因(reason、message)と対処方法(hint、retryable)を示します。詳しくは失敗理由をご覧ください。確保額は返金され、data.cost.amount_usdは0になります。
キャンセルした生成は、Webhookを送信しません。キャンセルしたのはお客様自身なので、結果が分かるためです。
ペイロード#
本文はJSON形式で、イベントtypeと生成情報dataを含みます。GET /generations/{id}から取得するものと同じオブジェクトです。
{
"type": "generation.succeeded",
"data": {
"id": "gen_8c2QmX1nq0k4Lw7Z",
"object": "generation",
"model": "bytedance/seedance-2.0-fast",
"endpoint": "bytedance/seedance-2.0-fast/text-to-video",
"mode": "text-to-video",
"status": "succeeded",
"input": {
"prompt": "ゴールデンアワーの光の中、陶芸家が花瓶を形作る様子をゆっくり寄って撮影。光に浮かぶほこり、浅い被写界深度。",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"generate_audio": true
},
"output": {
"video": {
"url": "https://cdn.cheapergen.com/outputs/gen_8c2QmX1nq0k4Lw7Z.mp4",
"content_type": "video/mp4"
}
},
"error": null,
"cost": {
"amount_usd": 0.4494,
"currency": "usd",
"estimated": false
},
"source": "api",
"created_at": "2026-10-15T09:21:04.512Z",
"started_at": "2026-10-15T09:21:09.031Z",
"completed_at": "2026-10-15T09:22:31.870Z",
"retention": "1d",
"expires_at": "2026-10-16T09:22:31.870Z",
"purged_at": null
}
}ヘッダー#
CheaperGen-Signaturestring- タイムスタンプと署名:
t=<unix seconds>,v1=<hex HMAC-SHA256>。 CheaperGen-Webhook-Idstring- この通知の一意のID(例:
whd_3f9c2a…)です。再試行しても変わらないため、重複排除に使えます。 Content-Typestringapplication/jsonUser-AgentstringCheaperGen-Webhooks/1.0
署名を検証する#
Webhookを信頼する前に、必ず署名を検証してください。公開URLには誰でもPOSTできます。ヘッダーは次の形式です:
CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- ヘッダーを
,で分割し、タイムスタンプtと署名v1を読み取ります。 - 署名対象の文字列を作成します。タイムスタンプ、リテラル文字
.、続いて受信したままの生のリクエスト本文を連結します。 - 署名シークレットをキーとして、その文字列のHMAC-SHA256を計算し、16進数に変換します。
- 一定時間比較を使って
v1と照合し、リプレイを防ぐため数分以上前のタイムスタンプは拒否してください。
署名シークレットの接頭辞はwhsec_です。APIキーの横にあるダッシュボードに表示されます。APIキーと同じようにサーバー側で管理してください。
コードからAPIキーで取得することもできます。GET /api/v1/webhooks/secretは{ "key": "whsec_…" }を返します。アカウントにまだシークレットがない場合は、最初の呼び出しで作成されます。
curl https://cheapergen.com/api/v1/webhooks/secret \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY"受信した本文をそのまま使う
受信したバイト列と照合してください。JSONを解析して再シリアライズすると、空白やキーの順序が変わり、署名が一致しなくなります。Node.js#
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes
/**
* Verify a CheaperGen webhook.
* @param rawBody the request body exactly as received (string or Buffer) — not re-serialized JSON
* @param header the value of the "CheaperGen-Signature" header
* @param secret your signing secret (whsec_…)
*/
export function verifyCheaperGenSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((kv) => kv.trim().split("=", 2)),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python#
import hashlib, hmac, time
TOLERANCE_SECONDS = 300 # reject deliveries older than 5 minutes
def verify_cheapergen_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""Verify a CheaperGen webhook. raw_body must be the exact bytes received."""
try:
parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)配信と再試行#
- 配信を確認するには、2xxステータスを10秒以内に返してください。リダイレクトは追跡されません。
- エンドポイントがタイムアウトした場合や2xx以外を返した場合は、最長1時間再試行します。最初の試行から約1、3、8、18、38、60分後(合計7回)に再試行し、その後は停止します。
410 Goneを返すと、再試行はただちに停止します。 - 各試行は新しい
tで署名し直されるため、再試行も最初の配信と同じく5分間のチェックを通過します。 - 配信は複数回届くことがあり、まれに順序が前後することもあります。ハンドラーは冪等にしてください。
CheaperGen-Webhook-Idヘッダー(同じ通知の再試行では常に同じ値)をキーにして、処理済みの通知は無視します。 - Webhookは通知であり、唯一の正本ではありません。通知を見逃しても生成情報は常に
GET /api/v1/generations/{id}から取得できます。保留中ジョブを定期的に照合すれば、例外にも対応できます。
ローカルでテストする#
Webhook URLは、HTTPS経由でインターネットからアクセスできる必要があります。開発時はトンネル(例:cloudflared tunnelまたはngrok http 3000)でローカルサーバーを公開し、そのHTTPS URLをwebhook_urlに指定してください。
不明な点や不足している情報がありますか?support@cheapergen.com — すべて目を通しています。