コンテンツにスキップ

Webhook

webhook は、アカウントで何かが起きたときに、Sakuranbo AI から自分のサーバーに HTTP リクエストを送る仕組みです。webhook を使うと、ポーリングせずに画像や動画のジョブの完了を知り、入金が届いたときに記録し、残高が少なくなったときに警告を受け取れます。

配信は Standard Webhooks の仕様に従い、すべて署名されています。サーバーは、リクエストを信頼する前に署名を検証する必要があります。署名を検証するを参照してください。

エンドポイントは、次のイベントを購読できます: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low。webhook.test も送られますが、テストイベントを依頼したときだけで、購読はできません。

  • job.succeeded: 画像または動画のジョブが completed になりました。data は、GET /v1/jobs/{id} が返すものと同じジョブで、outputs に署名付き URL が入ります。
  • job.failed: ジョブが failed または expired になりました。data はそのジョブで、data.status がどちらかを示します。failed のジョブでは、data.error に理由が入ります。expired のジョブでは、data.error は null です。モデルの提供元が内容を理由に拒否したジョブも、このイベントで届き、data.error.code は content_policy_violation です。
  • job.cancelled: ジョブが cancelled になりました。data はそのジョブです。
  • deposit.credited: 入金が残高に反映されました。data には、金額、日付、残高が入ります。
  • balance.low: 利用可能な残高が、ダッシュボードの設定で指定したアラートの基準を下回りました。data には、利用可能な残高とアラートの基準が入ります。
  • webhook.test: 依頼したテストイベントです。

ジョブのステータスはジョブを、残高は残高とキーの利用上限を参照してください。

すべての配信の本文は、次の 4 つのフィールドを持つ JSON です。

  • id: イベントの ID です。webhook-id ヘッダの値にもなります。
  • type: イベントの種類です。
  • timestamp: イベントが起きた時刻で、UTC の ISO 8601 形式です。
  • data: イベントの内容で、種類によって異なります。

知らないイベントの種類やフィールドは無視してください。Sakuranbo AI は、予告なく新しいイベントやフィールドを追加することがあります。

アラートの基準は、ダッシュボードの設定で指定します。balance.low イベントは、利用可能な残高が基準以上から基準未満になるたびに送られます。次のイベントが送られるのは、残高が基準以上に戻ったあとだけです。課金、入金した残高の失効、残高を減らす調整が、きっかけになります。金額の確保だけでは、きっかけになりません。基準を指定していない間は、何も送られません。

エンドポイントは、イベントを受け取る URL です。ダッシュボードでも API でも作成できます。

ダッシュボードでは、Webhookページを開きます。エンドポイントの作成と編集、配信の停止と再開、テストイベントの送信、秘密のローテーション、配信ログの確認ができます。

API では、POST /v1/webhook-endpoints を呼び出します。

ターミナルウィンドウ
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $SAKURANBO_API_KEY" \
-d '{
"url": "https://example.com/webhooks/cherry",
"events": [
"job.succeeded",
"job.failed",
"job.cancelled"
]
}'

リクエスト本文には、次のフィールドがあります。

名前型必須既定値制約
urlstringはい1〜2,048 文字
eventsstring[]いいえ次のいずれか: job.succeeded, job.failed, job.cancelled, deposit.credited, balance.low; 1 件以上
descriptionstringいいえ最大 256 文字

events を省略すると、エンドポイントは、作成した時点にあるすべてのイベントの種類を購読します。あとで追加されたイベントの種類は、エンドポイントに追加されません。受け取るには、PATCH /v1/webhook-endpoints/{id} を呼び出し、新しい種類を含むイベントの種類の全一覧を events に入れます。events は、一覧全体を置き換えます。

レスポンスには、エンドポイントの ID と、署名の秘密 secret が入ります。秘密は whsec_ で始まります。秘密の全文が現れるのは、このリクエストのレスポンスと、秘密のローテーションのレスポンスだけです。あとから読み取ることはできないため、すぐに保存してください。紛失したときは、秘密をローテーションしてください。

URL は、次の規則に従う必要があります。

  • https と既定のポートを使います。
  • ホストは公開された DNS 名です。IP アドレス、localhost、ドットのない名前、内部向けの名前は拒否されます。ユーザー名やパスワードを含む URL も拒否されます。
  • パスとクエリ文字列を含められます。

規則に反する URL には、400 と invalid_request を返します。error.param は url で、error.details.reason にどの規則に反したかが入ります。自分のマシンでテストするときは、公開の HTTPS トンネルを使って、外部から届くようにしてください。

1 つのアカウントに作成できるエンドポイントは、10 件までです。上限に達しているときは、既存のエンドポイントを削除してから追加してください。

Sakuranbo AI は、各イベントを、JSON の本文と次のヘッダを持つ POST リクエストとして送ります。

  • webhook-id: イベントの ID です。
  • webhook-timestamp: この試行の時刻で、Unix 秒です。
  • webhook-signature: 署名です。署名を検証するを参照してください。

サーバーの応答は、次の規則で扱われます。

  • 15 秒以内の 2xx は、イベントを受け取ったことを示します。時間のかかる処理は、応答してから行ってください。
  • Sakuranbo AI は、リダイレクトに従いません。3xx は失敗として扱われます。
  • 410 Gone を返すと、エンドポイントが停止します。停止中に起きたイベントは溜められず、再開しても再び送られません。再開するには、enabled を true にして PATCH /v1/webhook-endpoints/{id} を呼び出すか、ダッシュボードを使います。そのあとで、失敗した配信を手動で再送してください。
  • それ以外のステータス、タイムアウト、接続の失敗は、失敗として扱われます。Sakuranbo AI は再試行し、最初の試行を含めて合計で最大 8 回、約 31 時間にわたって送ります。最後の試行のあと、配信は failed になります。

受信側は、次の点を踏まえて書いてください。

  • イベントは、複数回届くことがあります。1 つのイベントのどの試行も、webhook-id と本文が同じです。処理した ID を保存して、重複を読み飛ばしてください。
  • イベントの順序は保証されません。timestamp と、data の状態を使ってください。
  • 処理しないイベントの種類にも 2xx を返してください。再試行されなくなります。

各配信は、エンドポイントの秘密で署名されています。署名を検証すると、リクエストが Sakuranbo AI から送られたもので、誰も書き換えていないことを確かめられます。方式は Standard Webhooks のバージョン v1 で、HMAC-SHA256 を使います。

  1. webhook-id、webhook-timestamp、 webhook-signature ヘッダを読み取ります。1 つでも欠けていれば、リクエストを拒否します。
  2. タイムスタンプが、現在時刻の前後 5 分以内であることを確かめます。これで、過去のリクエストの再利用(リプレイ)を防げます。
  3. 鍵を作ります。秘密から接頭辞 whsec_ を取り除き、残りを base64 からデコードします。鍵は、デコードしたバイト列であり、秘密の文字列そのものではありません。
  4. 署名の対象を作ります。ID ヘッダの値、ドット、タイムスタンプヘッダの値、ドット、生のリクエスト本文を、この順につなげたものです。
  5. 鍵を使って、署名の対象の HMAC-SHA256 を計算し、結果を標準の base64 でエンコードします。
  6. 署名ヘッダには、スペースで区切られた 1 つ以上の項目が入ります。各項目は、バージョン、カンマ、署名の順です。バージョンが v1 でない項目は読み飛ばします。いずれかの項目が計算結果と一致すれば、リクエストは有効です。比較は、定数時間で行ってください。
  7. JSON は、署名が有効だと確かめてから解析します。

受け取ったバイト列そのものを検証してください。JSON を解析してから直列化し直す Web フレームワークでは、バイト列が変わり、署名が一致しなくなることがあります。パーサーが動く前に、生の本文を読み取ってください。

TypeScript:

import { createHmac, timingSafeEqual } from "node:crypto";
/**
* Verifies a Sakuranbo AI webhook (Standard Webhooks) and returns the parsed event.
* rawBody must be the request body exactly as received, before any JSON parsing.
*/
export function verifyWebhook(
secret: string, // "whsec_..." as shown when the endpoint was created or its secret rotated
rawBody: string,
headers: Record<string, string | undefined>, // header names in lower case
toleranceSeconds = 300,
) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) throw new Error("missing webhook headers");
const sentAt = Number(timestamp);
const now = Math.floor(Date.now() / 1000);
if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > toleranceSeconds) {
throw new Error("timestamp outside the tolerance");
}
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
// During a secret rotation the header holds two signatures, separated by a space.
for (const entry of signatures.split(" ")) {
const [version, signature] = entry.split(",");
if (version !== "v1" || !signature) continue;
const received = Buffer.from(signature, "base64");
if (received.length === expected.length && timingSafeEqual(received, expected)) {
return JSON.parse(rawBody);
}
}
throw new Error("no matching signature");
}

Python:

import base64
import hashlib
import hmac
import json
import time
def verify_webhook(secret: str, raw_body: bytes, headers: dict, tolerance_seconds: int = 300):
"""Verify a Sakuranbo AI webhook (Standard Webhooks) and return the parsed event.
raw_body must be the request body exactly as received, before any JSON parsing.
"""
headers = {name.lower(): value for name, value in headers.items()}
message_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (message_id and timestamp and signatures):
raise ValueError("missing webhook headers")
try:
sent_at = int(timestamp)
except ValueError:
raise ValueError("invalid timestamp") from None
if abs(int(time.time()) - sent_at) > tolerance_seconds:
raise ValueError("timestamp outside the tolerance")
if secret.startswith("whsec_"):
secret = secret[len("whsec_"):]
key = base64.b64decode(secret)
signed = f"{message_id}.{timestamp}.".encode() + raw_body
expected = hmac.new(key, signed, hashlib.sha256).digest()
# During a secret rotation the header holds two signatures, separated by a space.
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version != "v1" or not signature:
continue
if hmac.compare_digest(base64.b64decode(signature), expected):
return json.loads(raw_body)
raise ValueError("no matching signature")

Standard Webhooks のライブラリも、同じ検証を行います。自分で書くよりライブラリを使いたいときは、npm または PyPI の standardwebhooks パッケージを使ってください。

秘密を紛失したときや、漏えいした可能性があるときは、秘密をローテーションします。ダッシュボードか POST /v1/webhook-endpoints/{id}/rotate-secret を使います。レスポンスに新しい秘密が入り、読み取れるのはこのときだけです。

ローテーションのあと 24 時間は、署名ヘッダに 2 つの署名が入り、1 つ目が新しい秘密、2 つ目が前の秘密のものです。その間は、どちらの秘密を持つ受信側でも、リクエストを検証できます。サーバーを新しい秘密に切り替えてください。その時間が過ぎると、前の秘密は使えなくなります。エンドポイントの previous_secret_expires_at に、その時刻が入ります。

その間にもう一度ローテーションすると、最初のローテーションより前の秘密は、すぐに使えなくなります。配信に署名するのは、最新の 2 つの秘密だけです。

POST /v1/webhook-endpoints/{id}/test は、エンドポイントが購読しているイベントに関係なく、1 つのエンドポイントに webhook.test イベントを送ります。エンドポイントが有効である必要があります。レスポンスは、ステータスが pending の新しい配信です。ダッシュボードにも、同じ操作のボタンがあります。

ターミナルウィンドウ
curl -X POST "https://api.sakuranbo.ai/v1/webhook-endpoints/we_0123456789abcdef0123456789abcdef/test" \
-H "Authorization: Bearer $SAKURANBO_API_KEY"

エンドポイントのすべての配信は、GET /v1/webhook-endpoints/{id}/deliveries とダッシュボードで新しい順に確認できます。各項目には、ステータス、試行回数、最後の HTTP ステータス、次の試行の時刻、サーバーからの応答の先頭部分が入ります。ログに、イベントの本文は含まれません。Sakuranbo AI は、ログを 30 日間保管します。

配信を再送するには、POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry を呼び出すか、ダッシュボードを使います。配信は同じイベント ID と同じ本文で送られ、新しい一連の試行が始まります。再送できるのは、succeeded か failed で終わった配信だけで、エンドポイントが有効である必要があります。

これらのエンドポイントはすべて、API キーが必要です。認証を参照してください。

  • POST /v1/webhook-endpoints: エンドポイントを作成します。
  • GET /v1/webhook-endpoints: エンドポイントを一覧します。
  • GET /v1/webhook-endpoints/{id}: エンドポイントを 1 件読み取ります。
  • PATCH /v1/webhook-endpoints/{id}: URL、イベント、説明、エンドポイントが有効かどうかを変更します。
  • DELETE /v1/webhook-endpoints/{id}: エンドポイントと配信ログを削除します。
  • POST /v1/webhook-endpoints/{id}/test: テストイベントを送ります。
  • POST /v1/webhook-endpoints/{id}/rotate-secret: 新しい秘密を作成します。
  • GET /v1/webhook-endpoints/{id}/deliveries: 配信ログを読み取ります。
  • POST /v1/webhook-endpoints/{id}/deliveries/{deliveryId}/retry: 配信を再送します。
コードHTTP ステータス種別説明
invalid_request400invalid_request_errorリクエストの形式が不正か、値が範囲外です。有効な値が分かる場合は details.allowed に入ります。
unauthorized401authentication_errorAPI キーが指定されていない、形式が不正、存在しない、失効済み、または期限切れです。レスポンスからは、どれに当たるかは分かりません。
webhooks_unavailable503api_errorこの環境では webhook をまだ使えません。時間をおいて再試行してください。

ほかのエンドポイントは、エンドポイントまたは配信がアカウントにないときに、404 と not_found を返します。停止中のエンドポイントへのテストイベントや再送には、400 と invalid_request を返します。すべてのコードは、エラーを参照してください。