ブログ/developers·2026年9月1日·2分·執筆:eroqチーム
非同期の動画生成API:ジョブ、ポーリング、署名付きWebhook
お金もクリップも失わずに非同期の動画生成APIを組み込む方法。ジョブの送信、ポーリングと署名付きWebhook、返金と再試行の安全な扱い方。
動画のレンダリングには1〜5分かかります。HTTPリクエストをそんなに長く開いたままにすべきではありません。ロードバランサーはタイムアウトし、モバイル回線は途切れ、ユーザーはページを再読み込みします。だからこそ、本格的な動画生成APIはどれも非同期です。ジョブを送信してIDを受け取り、結果はあとで知る。その「あとで」の細部こそ、連携がお金とクリップを失う場所です。ここでは、eroqの動画エンドポイントの仕組みと、正しい使い方を説明します。
仕様を1段落で
POST /v1/videos/generationsは定額のクレジット料金を課金してレンダリングを開始し、ジョブIDとともに202を返します。その後はGET /v1/videos/generations/{id}をポーリングする(無料、数秒ごと)か、ジョブ完了時に署名付きWebhookを受け取ります。失敗したレンダリング、または10分以内に完了しなかったレンダリングは自動で返金されます。利用ポリシーの範囲外のプロンプトにはcontent_blockedが返され、課金されることはありません。支払うのは、届いたクリップの分だけです。ジョブIDの有効期間は24時間です。
ジョブを送信する
curl https://eroq.ai/v1/videos/generations \
-H "Authorization: Bearer $EROQ_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "eroq-motion-one",
"prompt": "Slow push-in on a lighthouse keeper in a yellow raincoat climbing a spiral stone staircase at night, lantern swinging, warm practical light against cold blue storm windows, rain streaking the glass, tense and patient mood, 35mm film grain.",
"seconds": 10,
"aspect": "9:16",
"store": true
}'
レスポンスは次のとおりです。
{
"id": "b7e6c2d4-…",
"status": "processing",
"usage": { "credits_spent": 60, "credits_remaining": 887 }
}
特に注意すべきパラメーターが2つあります。
secondsはクリップの長さで、1〜30です。プランによる上限(従量課金は10秒、Hobbyは15秒、Creatorは20秒、Studio以上は30秒)があり、さらにエンジンが対応する長さに合わせて丸められます。Motion Oneは5秒または10秒、Seedance 2.5は4〜30秒の任意の長さをレンダリングします。丸めは課金の前に行われるので、料金は常に実際にレンダリングされるクリップと一致します。Motion Oneにseconds: 8を送ると、エンジンが対応する長さに丸められ、実際に作られたクリップの分が課金されます。対応する長さを事前に知りたい場合は/v1/enginesを確認してください。
aspectは16:9、9:16、1:1、21:9、4:3、3:4を受け付けます。エンジンがその比率にネイティブ対応していればそのまま渡され、そうでなければプロンプトに組み込まれます。エンジンへの強い要望ではありますが、確実な保証ではありません。エンジンごとの詳細はアスペクト比の項目にあります。
store: trueは任意ですが、本番環境では使う価値があります。完成したクリップはeroq Storeに送られ、ジョブにはジョブとともに失効するインラインのbase64ではなく、永続的なCDN URLが含まれます。追加料金は10 MBのブロックごとに2クレジットで、使い始めたブロックも1つと数えます(料金)。
方法A:ポーリング
ポーリングは無料で、想定している間隔は数秒ごとです。期限は10分の返金ウィンドウより少し長めに設定しましょう。そうすれば、止まったジョブはループが諦める前に自動で解決します。
const BASE = 'https://eroq.ai/v1'
const headers = { Authorization: `Bearer ${process.env.EROQ_API_KEY}` }
async function waitForClip(jobId, { every = 4000, deadline = 11 * 60_000 } = {}) {
const started = Date.now()
while (Date.now() - started < deadline) {
const job = await fetch(`${BASE}/videos/generations/${jobId}`, { headers }).then(r => r.json())
if (job.status === 'succeeded') return job.data[0].url // with store: true
if (job.status === 'failed') throw new Error(job.error?.code ?? 'generation_failed')
await new Promise(r => setTimeout(r, every))
}
throw new Error('timeout')
}
statusはprocessingからsucceededまたはfailedに進みます。failedの時点でクレジットはすでにウォレットに戻っているので、「再試行」とは「もう一度送信すること」であって、「請求について揉めること」ではありません。
方法B:署名付きWebhook
開発者ダッシュボードでエンドポイントを登録し、video.generation.succeededとvideo.generation.failedを購読させ、whsec_のシークレットをコピーします。シークレットは一度しか表示されません。配信はそれぞれJSONのPOSTです。
{
"id": "evt_9f1c…",
"type": "video.generation.succeeded",
"data": {
"id": "b7e6c2d4-…",
"result_url": "https://…/b7e6c2d4.mp4"
}
}
失敗したジョブには、result_urlの代わりにerrorオブジェクトが含まれます。メディアがペイロードで送られることはありません。URLを受け取るか、ジョブを取得します。
署名はStripe方式で、eroq-signatureヘッダーにt=<unix timestamp>,v1=<hex>の形で入ります。v1は、シークレットを使って${t}.${rawBody}から計算したHMAC-SHA256です。検証は生のバイト列に対して行い、JSONのパースはチェックのあとにしてください。
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')))
const expected = createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < toleranceSec
const a = Buffer.from(expected)
const b = Buffer.from(String(parts.v1 ?? ''))
return fresh && a.length === b.length && timingSafeEqual(a, b)
}
配信は10秒の制限時間で1回だけ試みられます。顧客のサーバーが落ちていても、顧客のレンダリングが失敗することはありません。ですから、すぐに2xxを返し、処理はそのあとで行い、通知を受け取れなかったものに備えてポーリングをフォールバックとして残しておきましょう。GET /v1/videos/generationsにある24時間分のキュー一覧は、まさにその照合のためにあります。
冪等性:実際にお金を失うのはここ
ここでの失敗パターンは、すべて重複です。ポーラーがすでに成功を確認したあとに届くWebhook、送信時のネットワークエラー後の再試行、202を受け取ってからデータベースに書き込むまでの間にクラッシュするワーカー。
- ジョブIDは、ユーザーに見せる状態と同じトランザクションで保存する。
202は課金が確定する瞬間です。ユーザーに「レンダリング中」と伝える前にそのIDがディスクに書かれていなければ、クラッシュひとつで見つけられないクリップが生まれます。再起動時にはキュー一覧と照合しましょう。 - ジョブIDでupsertし、イベントは
evt_のIDで重複を除く。2つの経路が同じ完了を報告することがあります。先に届いたほうが採用され、2つ目は何もしません。 - 送信時のネットワークエラーで、確認せずに再送信しない。リクエストは通っているかもしれません。二重に課金される前に、キュー一覧で直近1分以内に同じプロンプトのジョブがないか確認しましょう。
- 再試行は
failedのときだけ。返金はすでに済んでいるので、新たな送信は新たな1回分の課金になります。キーごとのレート制限を守りましょう。動画は基本枠で1分あたり6リクエストで、プランに応じて倍率がかかります。
定額のクレジット料金なら、これらすべてを簡単に監査できます。どのジョブも既知の1回の課金に対応し、レスポンスのusageブロックがその時点の残高を教えてくれます。
よくある質問
クリップはいつまでに取得すればよいですか?
ジョブIDは作成から24時間で失効し、インラインのbase64もそれとともに消えます。残り続けるCDN URLが欲しければstore: trueを使うか、完了時にダウンロードしてください。
Webhookのエンドポイントが落ちていたらどうなりますか?
1回試みて、それで終わりです。GET /v1/videos/generations/{id}をポーリングするか、直近24時間の一覧を取得して追いつきましょう。Webhookは便利な機能にすぎず、信頼できる情報源はポーリングです。
サウンドトラックは付けられますか?
veo-3-fastのみです。8秒固定のクリップに、環境音、効果音、セリフをネイティブにレンダリングします。上流でプロバイダーによるモデレーションがあり、Creatorプランが必要です。ほかのエンジンはすべて無音の動画をレンダリングします。エンジンのラインナップをご覧ください。
まずは無料の50クレジットから。APIキーを取得して、ジョブを1つ送信してみましょう。
この記事で使ったモデルで作ってみましょう。無料の50クレジットで始めるか、全エンジンと料金をご覧ください。