spicyapiドキュメント
API で開発する
本文

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 つのシークレットを共有します。

シークレットの表示は一度だけです

通常、アカウントには設定済みですが、その値はユーザーに未表示のため、値を取得できるものを作る必要があります。

  1. コンソール → Webhook を開きます。
  2. Webhook 署名キー カードで、未設定 なら 発行する、設定済み なら 再発行 を押します。
  3. すぐサーバーの 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 応答で再配信できます。共有アプリではテナントごとのタスク所有権も確認してください。

receiver.mjs
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-SignatureBase64 エンコードした 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 ではありません。

succeeded
{
  "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 です。

failed
{
  "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 つを結合します。

  1. タスク ID。X-Webhook-Payload-Version に従い、v1 はトップレベルの task_id、v2 は data.taskId(request_id ではない)。
  2. X-Webhook-Timestamp の値。
  3. 生のリクエスト本文の SHA-256 ダイジェスト。小文字の 16 進数。

署名に本文ダイジェストが必要で、受信側でも必ず計算します

タスク ID と日時だけを署名すると、正しい配信を 1 つ傍受した人が、その 2 値に自分の本文を組み合わせて再送でき、公開アルゴリズムどおりの検証でも通ってしまいます。

偽の「タスク完了、生成ファイルはここ」を正当化し、ユーザーへの請求や結果送付につながるため、ダイジェストは省略できません。

生のバイト列を使ってください

先にデシリアライズして再シリアライズしないでください。JSON ライブラリでキー順、空白、Unicode のエスケープが異なり、ほぼ確実に署名したバイト列と一致せず、毎回検証に失敗します。

生本文の取得方法:Express の express.raw()、Fastify の rawBody、Flask の request.get_data()、Django の request.body、FastAPI の await request.body()。

検証コード

Node.js / Express
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);
}
Python / Flask
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)
Go / net/http
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 を自分のエンドポイントに変えて実行してください。

Test your verifier with curl
# 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 点異なり、間違えるとエラーを出さず常に不一致になります。

  1. ダイジェストではなく本文自体を署名します。 内側の sha256 処理はありません。
  2. HMAC キーは whsec_ より後を Base64 デコードしたバイト列で、文字列そのものではありません。文字列を使うのが最も多い誤りで、エラーは出なくても署名が常に間違います。
Node.js
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 を自分のキューへ保存して応答します。通知処理内でダウンロード、変換、通知を行うとタイムアウトし、こちらでは再配信する一方、受信側ではすでに半分処理済みになり得ます。

再試行のスケジュール

再試行前回からの待ち時間
110 秒
230 秒
32 分
410 分
530 分
62 時間
76 時間

最初を含め、自動配信は 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 アドレスを用意するか、ローカルではポーリングを使ってください。

このページの内容