Webhook
v1・v2 の Webhook ペイロードを区別し、正しいタスク ID を選び、Node.js・Python・curl の動作する例で署名を検証します。
Webhook(コールバック)は、タスク終了時に SpicyAPI からユーザーのサーバーへ送るリクエストです。状態を何度も尋ねる必要がなくなります。createTask に callBackUrl を指定すると、最終状態に達した時に POST で配信します。成功タスクでは結果ファイルをダウンロードできるまで待つため、コールバックに有効なリンクを含めます。配信失敗は下記のスケジュールで再試行します。
最終状態だけが通知対象で、queued → running の通知はありません。
始める前に
- サーバーに公開 HTTPS アドレスが必要です。 インターネット経由で SpicyAPI から到達でき、
https://で始まる必要があります。http://localhostや社内・自宅ネットワーク内のアドレスは使えません。ローカル開発ではrecordInfoのポーリングを使うか、ngrok・Cloudflare Tunnel などで一時的な公開 HTTPS アドレスを作ってください。詳細は コールバック URL の制約です。 - 受信方法は 2 つあり、併用できます。個別タスクの
callBackUrlが最も手軽です。アカウントにエンドポイントを登録すれば、URL を毎回送らず全タスクを対象にできます。使い分けを参照してください。 - 「テストイベント送信」ボタンはありません。 先に 下の curl スクリプトで署名検証を試し、その後
callBackUrl付きの安価な実タスクを 1 件作ります。通常のタスクと同様に課金されます。コンソール → Webhook → Webhook 配信履歴 で各配信の状態を確認してください。
使い分け
タスクの callBackUrl | アカウント単位のエンドポイント | |
|---|---|---|
| 設定場所 | 各 createTask の 1 フィールド | API で一度登録(コンソールの画面はまだなし) |
| 配信対象 | そのタスクの最終状態 | 購読した各イベント種別。すべてのタスクが対象 |
| ヘッダー | X-Webhook-* | webhook-id / webhook-timestamp / webhook-signature |
| 本文 | recordInfo と同じ {code, msg, data} 形式 | id、type、data.object を持つイベントオブジェクト |
| 署名用シークレット | アカウントごとに 1 つ。Webhook 署名キー に設定 | エンドポイントごとに 1 つ。作成・ローテーション時に一度だけ表示 |
| 無停止のキーローテーション | 不可。旧シークレットは直ちに検証に使えなくなる | 可。猶予期間は両方の署名を送る |
一方が他方を置き換えるわけではありません。callBackUrl があり、アカウントにも一致する購読があるタスクでは、2 形式で 1 回ずつ、合計 2 回配信します。これは通常の構成で、誤りではありません。
新規連携で特別な理由がなければ、アカウント単位のエンドポイントを登録してください。URL を一度だけ設定し、同時切り替えなしでシークレットを更新でき、ペイロードも一般的な Webhook ライブラリが理解する形式です。
特記がない限り、以下は callBackUrl の配信を説明します。違いは アカウント単位のエンドポイントで扱います。
先に署名用シークレットを設定する
すべての配信をアカウントの署名用シークレットで署名し、受信サーバーが SpicyAPI から来たことを確認できるようにします。同じアカウントの API キーは 1 つのシークレットを共有します。
シークレットの表示は一度だけです
通常、アカウントには設定済みですが、その値はユーザーに未表示のため、値を取得できるものを作る必要があります。
- コンソール → Webhook を開きます。
- Webhook 署名キー カードで、未設定 なら 発行する、設定済み なら 再発行 を押します。
- すぐサーバーの
SPICY_WEBHOOK_SECRETにコピーし、保存しました を押します。再表示できず、紛失したら再ローテーションが必要です。
ローテーションは即時反映し、旧シークレットは直ちに検証に使えなくなります。再試行中の配信にも新しいものを使います。更新直後にすべての受信先を更新してください。
シークレットがないと署名できず、各試行は失敗として再試行スケジュールに入ります。その間に通知を逃したタスクは recordInfo で確認してください。
SDK の検証ヘルパーから始める
新しい v2 受信処理では @spicyapi/sdk/webhooks の verifyWebhook を使います。生の本文の署名と 5 分間のタイムスタンプ許容範囲を確認し、taskId と deliveryId を返します。Node.js crypto を使うため、サーバーで実行してください。
以下は Express と PostgreSQL の例です(npm install @spicyapi/sdk express pg)。通常の PG* 環境変数でデータベースを設定し、コンソールの署名用シークレットを SPICY_WEBHOOK_SECRET に保存して、HTTPS 経由で提供します。本文にはプロンプトを含む完全な input が入るため、例は 10 MB まで許可しています。自分の用途に合わせて上限を決め、次のアプリ用テーブルを一度作成してください。
CREATE TABLE spicy_tasks (task_id text PRIMARY KEY);
CREATE TABLE spicy_webhook_inbox (
delivery_id text PRIMARY KEY,
task_id text NOT NULL REFERENCES spicy_tasks(task_id),
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);自分で送信したタスクの taskId をすべて spicy_tasks に保存します。受信側はそのタスクだけを受け付けます。極めて早いコールバックが保存より先に届いても、非 2xx 応答で再配信できます。共有アプリではテナントごとのタスク所有権も確認してください。
import express from "express";
import pg from "pg";
import { verifyWebhook } from "@spicyapi/sdk/webhooks";
const secret = process.env.SPICY_WEBHOOK_SECRET;
if (!secret) throw new Error("Set SPICY_WEBHOOK_SECRET");
const db = new pg.Pool();
const app = express();
app.post("/hooks/spicy", express.raw({ type: "application/json", limit: "10mb" }), async (req, res) => {
if (req.get("X-Webhook-Payload-Version") !== "2") return res.sendStatus(400);
let event;
try {
event = verifyWebhook({
rawBody: req.body,
timestamp: req.get("X-Webhook-Timestamp") ?? "",
signature: req.get("X-Webhook-Signature") ?? "",
payloadVersion: 2,
secret,
maxBodyBytes: 10 * 1024 * 1024,
});
if (!event.deliveryId) return res.sendStatus(400);
} catch { return res.sendStatus(401); }
try {
const owned = await db.query("SELECT 1 FROM spicy_tasks WHERE task_id=$1", [event.taskId]);
if (!owned.rowCount) return res.sendStatus(404);
await db.query(
"INSERT INTO spicy_webhook_inbox(delivery_id,task_id,payload) VALUES($1,$2,$3) ON CONFLICT(delivery_id) DO NOTHING",
[event.deliveryId, event.taskId, JSON.stringify(event.payload)],
);
return res.sendStatus(204);
} catch { return res.sendStatus(503); }
});
app.listen(8080, "127.0.0.1");この生本文用ルートは、全体の express.json() ミドルウェアより前に登録します。受信箱への保存がコミットしてから 2xx を返し、実処理はバックグラウンドワーカーで行います。主キーで重複配信を何もしない操作にします。成功扱いや費用確定の前に data.state と data.settled を確認してください。保存したメディア URL が期限切れなら、結果が保持されている間にタスクを再取得します。
この新規連携の例は v2 を受け付けます。既存の v1 受信処理には、以下のバージョン対応の検証を残してください。同じ SDK ヘルパーは payloadVersion: 1 にも対応しますが、v1 に deliveryId はありません。
リクエスト
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/1
X-Webhook-Timestamp: 1787045531
X-Webhook-Signature: 9tXk… (base64)
X-Webhook-Payload-Version: 2| ヘッダー | 説明 |
|---|---|
X-Webhook-Timestamp | 今回の配信の Unix 秒。リプレイ防止に使用 |
X-Webhook-Signature | Base64 エンコードした HMAC-SHA256 |
X-Webhook-Payload-Version | 本文形式のバージョン。新規連携はデフォルト 2、過去の配信は 1 の場合もある。必ずこのヘッダーで分岐。ペイロードを参照 |
User-Agent | 常に SpicyAPI-Webhook/1 |
リダイレクトには従いません。エンドポイントの 3xx は配信失敗として扱います。タイムアウトは 15 秒です。
ペイロード
v2 は recordInfo と同じ、v1 は旧来の平坦な形式です
バージョン 2 は GET /jobs/recordInfo と同じ形式です。{code, msg, data, request_id} の共通形式と、data 内の同じ camelCase 項目を使います。1 には共通の外枠がなく、トップレベルに snake_case のタスク項目があります。
新規連携は v2 で組みますが、検証の入口で X-Webhook-Payload-Version を読み、v1 ならトップレベルの task_id、v2 なら data.taskId からタスク ID を取得してください。
以下の例の MODEL_ID_FROM_CATALOG は、認証済みライブカタログから元々選んだ正確なモデル ID の代用です。そのまま使う ID ではありません。
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w4",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "succeeded",
"input": { "prompt": "a folded paper lantern, hard side light" },
"output": {
"assets": [
{
"key": "tasks/2026/08/28/job_01k3m8x9q2z4v7n5p6r8s0t1w3/9f3c1d0a7b4e2f68c5a1d3e9b0472fa1.png",
"url": "https://example.r2.cloudflarestorage.com/results/result.png?X-Amz-Signature=SIGNATURE_FROM_RESPONSE",
"expiresAt": "2026-08-28T09:32:11Z",
"mime": "image/png",
"width": 1024,
"height": 1024,
"bytes": 1483920
}
]
},
"cost": "0.008",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:11Z"
}
}上は v2 です。data の項目一覧は 非同期タスクにあり、recordInfo と v2 コールバックの両方に適用するため、ここでは繰り返しません。
recordInfo と同様、準備済みの data.output.assets[] に url、expiresAt、メディアのメタデータを含めます。API 認証情報なしで直接取得でき、ダウンロードチケットは不要です。cost などの金額は $ のない小数文字列です。
準備完了時にタスクごとに 1 イベント
各タスクのコールバックイベントは 1 つです。失敗時はその瞬間に作成し、成功時は結果ファイルのコピー後に作るため、通常はすべてのアセットに url があります。再試行は同じイベントを再送し、一時メディアリンクだけを更新します。
後から変わり得るものが 1 つありますが、追加のコールバックは発生しません。
data.settledがfalse→ 最終請求が未確定です。後でrecordInfoから最終costを取得してください。
まれに結果ファイルを全く保存できなくても、通知は届きます。そのアセットは unavailable: true で url がありません。全ファイルを保存できなかった場合は課金せず、cost は "0" です。
state: "succeeded"、settled: true で全アセットに url がある通知は完全で、追加照会なしに使えます。
v2 の code は失敗タスクでも常に 200
code でタスク成功を判断しないでください
共通形式の code は今回の配信の形式が正常かを示し、生成の成否ではありません。失敗タスクの通知も 200 です。
{
"code": 200,
"msg": "success",
"request_id": "whd_01k3m8x9q2z4v7n5p6r8s0t1w5",
"data": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"model": "MODEL_ID_FROM_CATALOG",
"state": "failed",
"errorCode": "upstream_failed",
"errorMessage": "Generation failed; the charge has been refunded",
"cost": "0",
"settled": true,
"createdAt": "2026-08-28T09:12:04Z",
"completedAt": "2026-08-28T09:12:39Z"
}
}if (body.code === 200) markSucceeded() はすべての失敗タスクを成功扱いにし、表面上は壊れて見えません。成否は data.state、理由は data.errorCode と data.errorMessage にあります。通知には元になるリクエストヘッダーがないため、errorMessage はアカウントの言語設定に従い、デフォルトは英語です。エラーメッセージの言語を参照してください。
失敗タスクを取得しても 200 を返す recordInfo と同じです。意図的に揃え、同じデータが 2 通りの読み方になる問題を防いでいます。
v2 の request_id は配信 ID:重複排除のキーにする
共通形式の request_id は whd_… の形式です。タスクではなく今回の配信記録を識別します。
- 再試行でも変わりません。 自動再試行も手動再配信も同じ業務イベントと
request_idを使います。一時メディアのurlとexpiresAtだけを毎回更新するため、v2 を本文のハッシュで重複排除しないでください。 - 自分のデータベースで一意インデックスにし、競合時は 2xx を返すだけで、処理済みか確認できます。
recordInfoの同じ位置は HTTP リクエスト ID(req_…)です。どちらも今回の配信を識別するという意味は同じです。
v2 のタスクは data.taskId で「どのタスクか」、request_id は「どの配信か」を示します。v1 には request_id がないため、task_id、状態、本文ダイジェストからキーを作ります。
バージョンヘッダーは本文のバージョンを示す
X-Webhook-Payload-Version はこの本文を作ったバージョンであり、現在のプラットフォーム設定ではありません。
業務ペイロードとバージョンはキュー投入時に固定します。再試行は一時 URL だけを更新し、実際に送る本文を署名します。最新のタスクのスナップショットへ差し替えません。ヘッダーも本文に合わせないと、数時間待った通知のバージョンと内容が食い違い、ヘッダーで分岐する受信処理を壊します。
v1 は固定された互換層です。新規開発には使わないでください
バージョンは SpicyAPI が管理する全体の互換設定で、ユーザーは選べません。新規配信は 2 です。設定は旧形式の 1 に固定でき、snake_case、共通の外枠なし、トップレベルの項目、input・settled・completedAt の欠落が特徴です。旧形式の連携を更新当日に壊さないためだけに残しています。
v1 に新しい項目は追加しません。 以後の追加は v2 のみです。利用者がいないことを確認後、設定とともに削除します。
新規は v2 で組んでください。過去の v1 通知が再試行される場合もあるため、JSON の形から推測せず X-Webhook-Payload-Version を読み、下記のように署名検証・解析の両方で分岐します。
署名
signature = base64( HMAC-SHA256( secret, "<task_id>.<timestamp>.<sha256_hex(raw_body)>" ) ). で次の 3 つを結合します。
- タスク ID。
X-Webhook-Payload-Versionに従い、v1 はトップレベルのtask_id、v2 はdata.taskId(request_idではない)。 X-Webhook-Timestampの値。- 生のリクエスト本文の SHA-256 ダイジェスト。小文字の 16 進数。
署名に本文ダイジェストが必要で、受信側でも必ず計算します
タスク ID と日時だけを署名すると、正しい配信を 1 つ傍受した人が、その 2 値に自分の本文を組み合わせて再送でき、公開アルゴリズムどおりの検証でも通ってしまいます。
偽の「タスク完了、生成ファイルはここ」を正当化し、ユーザーへの請求や結果送付につながるため、ダイジェストは省略できません。
生のバイト列を使ってください
先にデシリアライズして再シリアライズしないでください。JSON ライブラリでキー順、空白、Unicode のエスケープが異なり、ほぼ確実に署名したバイト列と一致せず、毎回検証に失敗します。
生本文の取得方法:Express の express.raw()、Fastify の rawBody、Flask の request.get_data()、Django の request.body、FastAPI の await request.body()。
検証コード
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.SPICY_WEBHOOK_SECRET;
// The raw bytes are required. express.json() consumes them.
// express.raw() defaults to a 100 KB limit; the body carries your full input.
app.post('/hooks/spicy', express.raw({ type: 'application/json', limit: '10mb' }), (req, res) => {
if (!verify(req.body, req.headers, SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
const version = req.headers['x-webhook-payload-version'];
const task = version === '1' ? event : event.data;
// Acknowledge first, work later. Do not make us wait on your pipeline.
res.status(200).end();
enqueue(task);
});
function verify(rawBody, headers, secret) {
const ts = headers['x-webhook-timestamp'];
const sig = headers['x-webhook-signature'];
const version = headers['x-webhook-payload-version'];
if (!ts || !sig || (version !== '1' && version !== '2')) return false;
// Replay protection: reject anything outside the tolerance window,
// however valid its signature
const timestamp = Number(ts);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
// Nothing parsed out of an unverified body can be trusted yet.
let event;
try {
event = JSON.parse(rawBody.toString('utf8'));
} catch {
return false;
}
const taskId = version === '1' ? event.task_id : event.data?.taskId;
if (typeof taskId !== 'string' || !taskId) return false;
const digest = crypto.createHash('sha256').update(rawBody).digest('hex');
const expected = crypto
.createHmac('sha256', secret)
.update(`${taskId}.${ts}.${digest}`)
.digest('base64');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
// timingSafeEqual throws on a length mismatch, so check that first
if (a.length !== b.length) return false;
// Constant time — a plain === leaks information through timing
return crypto.timingSafeEqual(a, b);
}import base64, hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["SPICY_WEBHOOK_SECRET"]
@app.post("/hooks/spicy")
def hook():
# get_data() returns the raw bytes; request.json does not
raw = request.get_data()
if not verify(raw, request.headers, SECRET):
return "", 401
event = json.loads(raw)
version = request.headers["X-Webhook-Payload-Version"]
task = event if version == "1" else event["data"]
enqueue(task) # acknowledge first, work later
return "", 200
def verify(raw_body: bytes, headers, secret: str) -> bool:
ts = headers.get("X-Webhook-Timestamp")
sig = headers.get("X-Webhook-Signature")
version = headers.get("X-Webhook-Payload-Version")
if not ts or not sig or version not in ("1", "2"):
return False
# Replay protection: reject anything outside the tolerance window
try:
timestamp = int(ts)
event = json.loads(raw_body)
except (ValueError, TypeError, json.JSONDecodeError):
return False
if abs(time.time() - timestamp) > 300:
return False
# v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
# Nothing in an unverified body can be trusted yet.
task_id = event.get("task_id") if version == "1" else event.get("data", {}).get("taskId")
if not isinstance(task_id, str) or not task_id:
return False
digest = hashlib.sha256(raw_body).hexdigest()
expected = base64.b64encode(
hmac.new(secret.encode(), f"{task_id}.{ts}.{digest}".encode(), hashlib.sha256).digest()
).decode()
# Constant time
return hmac.compare_digest(expected, sig)package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"math"
"net/http"
"os"
"strconv"
"time"
)
var secret = []byte(os.Getenv("SPICY_WEBHOOK_SECRET"))
// v1 keeps the task ID at top-level task_id; v2 uses data.taskId.
type event struct {
TaskID string `json:"task_id"`
Data struct {
TaskID string `json:"taskId"`
} `json:"data"`
}
func hook(w http.ResponseWriter, r *http.Request) {
// The raw bytes are required. Read them once, and never re-encode
// the parsed JSON: key order and escaping would not survive.
// The body contains the full input, so allow up to 10 MB.
rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if !verify(rawBody, r.Header) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var parsed event
if err := json.Unmarshal(rawBody, &parsed); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
taskID := parsed.Data.TaskID
if r.Header.Get("X-Webhook-Payload-Version") == "1" {
taskID = parsed.TaskID
}
// Acknowledge first, work later. Do not make us wait on your pipeline.
w.WriteHeader(http.StatusOK)
enqueue(taskID)
}
func verify(rawBody []byte, headers http.Header) bool {
ts := headers.Get("X-Webhook-Timestamp")
signature := headers.Get("X-Webhook-Signature")
version := headers.Get("X-Webhook-Payload-Version")
if ts == "" || signature == "" || (version != "1" && version != "2") {
return false
}
// Replay protection: reject anything outside the tolerance window,
// however valid its signature.
timestamp, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
return false
}
// Nothing parsed out of an unverified body can be trusted yet.
var parsed event
if err := json.Unmarshal(rawBody, &parsed); err != nil {
return false
}
taskID := parsed.Data.TaskID
if version == "1" {
taskID = parsed.TaskID
}
if taskID == "" {
return false
}
digest := sha256.Sum256(rawBody)
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(taskID + "." + ts + "." + hex.EncodeToString(digest[:])))
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Constant time — a plain == leaks information through timing.
return hmac.Equal([]byte(expected), []byte(signature))
}
func enqueue(taskID string) {
// Your own queue. Return from the handler before doing any real work.
_ = taskID
}
func main() {
http.HandleFunc("/hooks/spicy", hook)
_ = http.ListenAndServe(":8080", nil)
}curl で検証をテストする
ペイロードの v2 succeeded 例を payload.json に保存し、受信側と同じシークレットを SPICY_WEBHOOK_SECRET に設定します。末尾の URL を自分のエンドポイントに変えて実行してください。
# Sign a payload file yourself and post it at your own endpoint. This is the
# only way to confirm your verification code actually passes before go-live.
BODY_FILE=payload.json
VERSION=2
TS=$(date +%s)
TASK_ID=$(jq -r --arg version "$VERSION" \
'if $version == "1" then .task_id else .data.taskId end' "$BODY_FILE")
DIGEST=$(openssl dgst -sha256 -hex "$BODY_FILE" | awk '{print $NF}')
SIG=$(printf '%s.%s.%s' "$TASK_ID" "$TS" "$DIGEST" \
| openssl dgst -sha256 -hmac "$SPICY_WEBHOOK_SECRET" -binary \
| base64)
curl -X POST https://your-app.example.com/hooks/spicy \
-H "Content-Type: application/json" \
-H "X-Webhook-Timestamp: $TS" \
-H "X-Webhook-Signature: $SIG" \
-H "X-Webhook-Payload-Version: $VERSION" \
--data-binary "@$BODY_FILE"期待する結果は 2xx です。次に SPICY_WEBHOOK_SECRET の 1 文字を変えて再実行し、必ず 401 になることを確認します。まだ 2xx なら署名を検証していません。上の SDK 受信例は spicy_tasks にない ID に 404 を返すため、例の taskId を先に保存するか、単純な検証コードで試してください。
アカウント単位のエンドポイント
エンドポイントは、希望するイベント一覧とともにアカウントへ登録した URL です。作成後は一致するイベントをすべて配信し、タスクごとに URL を渡す必要はありません。
登録する
API から登録します。
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/spicy",
"enabledEvents": ["task.completed"],
"description": "production"
}'登録用のコンソール画面はまだありません
現在は API のみです。コンソール → Webhook は callBackUrl 用のアカウント共通シークレットと配信一覧を扱い、エンドポイント登録画面はありません。この節の操作はここに示す 4 呼び出しで行い、ブラウザーと同じコンソールのセッション Cookie が必要です。sk-spicy- キーではありません。
レスポンスは whsec_… 形式の secret を返し、表示は一度だけです。一覧も詳細も以後は返しません。紛失時はローテーションするしかありません。
1 アカウントに 16 エンドポイントを登録できます。URL は公開 HTTPS で、保存時と、配信時に実際に解決されたアドレスの両方を確認します。
購読できるイベント
| イベント | 発生条件 |
|---|---|
task.completed | 以下の 4 種類すべての省略形。区別する必要がなければこれを購読 |
task.succeeded | タスクの成功 |
task.failed | タスク失敗。通常は仮押さえを解放。モデルページで明示した例外のコンテンツ拒否は課金される場合がある |
task.canceled | 過去記録用。現在受理するタスクはキャンセル不可 |
task.expired | タスクが期限を超え、残高の仮押さえを解放 |
GET /console/v1/webhook-events はこの一覧を実行時に返すため、固定リストなしで選択 UI を作れます。
4 種のうち 3 種を列挙せず task.completed を購読してください
succeeded、failed、canceled を指定して expired を忘れるのがよくある誤りで、コードが永遠にタスクを待つ原因になります。task.completed ならこの漏れを防げます。
未知のイベント名は無視せず保存時に拒否します。誤記は 6 週間後に通知の欠落で気づくのでなく、登録時に失敗するべきです。同じ理由でワイルドカード("*")も受け付けません。
配信
POST /hooks/spicy HTTP/1.1
Host: your-app.example.com
Content-Type: application/json
User-Agent: SpicyAPI-Webhook/2
webhook-id: msg_01k3m8x9q2z4v7n5p6r8s0t1w3
webhook-timestamp: 1787045531
webhook-signature: v1,9tXk…{
"id": "msg_01k3m8x9q2z4v7n5p6r8s0t1w3",
"type": "task.succeeded",
"created": 1787045531,
"api_version": "2026-09-20",
"data": {
"object": {
"taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
"state": "succeeded",
"settled": true,
"cost": "0.0300"
}
}
}data.object は recordInfo と同じタスク記録で、現在の状態ではなくイベント作成時のスナップショットです。3 日遅れて処理しても、その瞬間の値を読みます。
費用の確定は settled で確認します
精算が遅れる場合、課金処理より先にタスクが最終状態になり、イベントは settled: false、cost は見積もり額のまま届きます。後から訂正イベントは送りません。 正確な金額が必要なら settled が false の時に recordInfo を読みます。
署名の検証
signature = "v1," + base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw_body>" ) )Standard Webhooks 仕様に従うため、Stripe や Replicate 用のライブラリもヘッダー名を変えるだけで使えます。上の callBackUrl 署名とは 2 点異なり、間違えるとエラーを出さず常に不一致になります。
- ダイジェストではなく本文自体を署名します。 内側の
sha256処理はありません。 - HMAC キーは
whsec_より後を Base64 デコードしたバイト列で、文字列そのものではありません。文字列を使うのが最も多い誤りで、エラーは出なくても署名が常に間違います。
import crypto from 'node:crypto';
// req.body must be the raw bytes, not a parsed object
function verify(req, secret) {
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const id = req.headers['webhook-id'];
const ts = req.headers['webhook-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected =
'v1,' +
crypto.createHmac('sha256', key).update(`${id}.${ts}.`).update(req.body).digest('base64');
// The header may hold several space-separated signatures during a rotation
return String(req.headers['webhook-signature'])
.split(' ')
.some((got) => got.length === expected.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected)));
}同じメッセージの再試行では webhook-id は変わりません。重複排除のキーに使ってください。 webhook-timestamp は重複排除でなくリプレイ防止のため、毎回更新します。
無停止でシークレットを更新する
curl -X POST 'https://api.spicyapi.ai/console/v1/webhook-endpoints/whe_…/rotate-secret' \
-H 'Content-Type: application/json' \
-d '{"gracePeriodSeconds": 86400}'猶予期間は 1 つのヘッダーに空白で区切った 2 つの署名を入れ、どちらかが一致すれば有効です。上の .some(…) はそのためです。安全な順序は、両方を受け付ける受信処理をデプロイ → ローテーション → 新シークレットへ置き換え → 猶予期間終了です。停止は不要です。
デフォルトは 24 時間、最大は 7 日です。本文をすべて省略するとデフォルトになります。
エンドポイントが応答しなくなった場合
下記のスケジュールで再試行し、さらに次を適用します。
410 Goneは即時にエンドポイントを無効化し、キュー内のメッセージを取り下げます。存在しないと通知されたアドレスへの再送は不要です。GET /console/v1/webhook-endpoints/{id}はstatus: "disabled"とdisabledReason: "gone_410"を返します。- その他の失敗は
consecutiveFailuresを加算し、同じ呼び出しで返します。現在、自動で無効化することはありません。
故障したエンドポイントも購読を続けます
数日間の連続失敗による自動無効化は未実装です。停止していても 410 以外を返すなら、すべてのイベントを受け取り、各イベントで全再試行枠を使います。自分で consecutiveFailures を確認し、修理中は PATCH と {"enabled": false} で無効化してください。
balance.low と webhook.endpoint.disabled もまだ利用できず、上の一覧にはありません。
再有効化すると失敗カウンターを消去するため、修理済みエンドポイントに古い失敗数を持ち越しません。
削除する
削除すると今後の配信が止まります。すでにキューにあるメッセージは履歴を保持し、終了結果を確認できますが、エンドポイントなしでは署名できないため、配信ログから消えず失敗で終わります。
返すレスポンス
2xx は受領を示します。それ以外、接続失敗、15 秒超過は失敗として再試行します。
実処理より先に受領を返してください。 タスク ID を自分のキューへ保存して応答します。通知処理内でダウンロード、変換、通知を行うとタイムアウトし、こちらでは再配信する一方、受信側ではすでに半分処理済みになり得ます。
再試行のスケジュール
| 再試行 | 前回からの待ち時間 |
|---|---|
| 1 | 10 秒 |
| 2 | 30 秒 |
| 3 | 2 分 |
| 4 | 10 分 |
| 5 | 30 分 |
| 6 | 2 時間 |
| 7 | 6 時間 |
最初を含め、自動配信は 8 回です。予定待ち時間の合計は 8 時間 42 分 40 秒で、リクエスト時間とスケジュールにより実時間は延びます。使い切ると自動再試行が停止し、コンソール → Webhook → Webhook 配信履歴 に失敗と表示します。そこで 再配信 を選ぶと再びキューへ入ります(手動は毎分 6 回まで)。自動再試行中、配信済み、保持設定で内容削除済みの通知は手動再送できず、recordInfo を使います。
再試行ごとに新しく署名します
日時は最初の試行ではなくその配信の瞬間です。そうしないと 6 時間後の再送に 6 時間前の日時が付き、正しく実装したリプレイ防止で拒否され、必要な時に再試行が機能しません。
配信間で日時をキャッシュ・比較しないでください。 毎回、現在時刻と照合します。
冪等性
再試行、ネットワークの重複、手動再配信で、同じタスクの通知が複数回届く場合があります。1 回限りとは考えないでください。
v2 は共通形式の request_id(whd_…)で重複排除します。配信記録を識別し、再試行でも同じなのでキーに適しています。自分のデータベースで一意インデックスにし、競合時は 2xx を返すのが最も簡単です。
v1 に request_id はありません。受信する場合は task_id、状態、生本文のダイジェストからキーを作ります。将来、同じタスクに別の最終状態記録ができると抑止してしまうため、タスク ID だけで重複排除しないでください。
コールバック URL の制約
httpsのみです。本文にプロンプトと署名付き結果リンクを含むため、平文のhttp://は拒否します。- 明示できるポートは
443または80だけです。https://のデフォルトポートは問題ありません。 - URL にユーザー名・パスワードを含めないでください。
- ホスト名が解決でき、解決されたすべてのアドレスが公開である必要があります。ループバック、プライベートネットワーク、リンクローカル、クラウドのメタデータアドレスは拒否します。
- DNS リバインディングを防ぐため、接続時にも解決した IP を再確認します。
タスク作成、ストリーミング、見積もりで不正な callBackUrl を指定すると、HTTP 400、業務 code: 400、msg: "Invalid callback URL"、request_id を返します。送信した URL、ホスト名、IP、DNS 診断はレスポンスに返しません。
保存時・接続時の検証は、SpicyAPI のサーバーを踏み台にして非公開ネットワークへ入ることを防ぎます。そのため、ローカル開発で http://localhost:3000 は使えません。ngrok・Cloudflare Tunnel などで公開 HTTPS アドレスを用意するか、ローカルではポーリングを使ってください。

