Webhook
Webhook機能を使用すると、コンテンツの作成、更新、削除が行われた際に、指定したURLに通知を送信できます。これにより、外部システムやサービスとの連携が可能です。
機能
- コンテンツの作成、更新、削除を通知
- テーブルごとに通知するイベントを指定
- カスタムリクエストヘッダーに対応
- 配信失敗時の自動リトライ
- Ed25519 署名による送信元の検証
設定方法
Orizmコンソールのプロジェクト設定からWebhookを設定できます。
コマンドラインから設定する場合は webhook コマンド を参照してください。
プロジェクト設定を開く

Webhook設定を開く

Webhookの作成をクリック

Webhookの作成

| プロパティ名 | 概要 |
|---|---|
| Name | コンソール上の表示用です |
| URL | 指定したURLにPOSTリクエストでイベントを通知します |
テーブルとイベントを選択
Table Events で通知を受け取るテーブルとイベントを選択します。
Add Table Eventをクリックするとテーブルを追加しますAdd All Tablesをクリックすると全てのテーブルを追加します
特定のイベント通知が不要な場合は、チェックボックスを解除することで通知を無効にできます。
Save をクリックすると設定が保存されます。

カスタムリクエストヘッダー
Custom Request Headers で必要に応じて、HTTPリクエストに任意のヘッダーを追加できます。

サポートしているイベント
| イベント名 | 説明 |
|---|---|
content.create | コンテンツが新規作成された時 |
content.update | コンテンツが更新された時 |
content.delete | コンテンツが削除された時 |
編集操作とイベントの対応
公開や非公開には専用のイベントがなく、いずれもcontent.updateとして通知されます。
| 編集操作 | 通知されるイベント | old |
|---|---|---|
| コンテンツの作成 | content.create | なし |
| コンテンツの更新 | content.update | あり |
| コンテンツの公開 | content.update | なし |
| コンテンツの非公開 | content.update | なし |
| コンテンツの削除 | content.delete | なし |
| 予約公開・予約非公開 | content.update | なし |
予約公開・予約非公開は1分ごとに実行対象を確認するため、指定した時刻から最大1分ほど遅れて通知されます。
Webhookペイロード
Webhookがトリガーされると、指定したURLにPOSTリクエストで以下のようなJSON形式のデータが送信されます。
{
"event": "content.create",
"timestamp": "2025-01-01T00:00:00.000Z",
"project": "projectName",
"table": "tableName",
"content": {
"id": "xxxxxxxx"
...
}
}event
トリガーされたイベントの名前です。サポートしているイベント のイベント名が入ります。
timestamp
イベントの発生時刻です。
ISO 8601形式で表現されます。(例: 2025-01-01T00:00:00.000Z)
project
イベントが発生したプロジェクト名です。
table
イベントが発生したテーブル名です。
content
イベントが発生した際のコンテンツです。
old
イベントが発生した際の変更前のコンテンツです。
コンテンツの更新の場合のみ存在します。
配信の仕様
| 項目 | 仕様 |
|---|---|
| HTTPメソッド | POST |
| リクエストヘッダー | Content-Type: application/json、User-Agent: superx-webhook、署名ヘッダー(webhook-id、webhook-timestamp、webhook-signature)、および設定したカスタムリクエストヘッダー |
| タイムアウト | 5秒 |
| 成功の判定 | ステータスコードが2xx |
| リトライする失敗 | タイムアウト、ネットワークエラー、5xxのレスポンス |
| リトライしない失敗 | 4xxのレスポンス |
| 試行回数 | 初回を含めて最大10回 |
| リトライの間隔 | 指数バックオフ(1秒から始まり、失敗するたびに倍になります) |
- 通知先が複数設定されている場合、宛先ごとに独立して配信とリトライを行います。失敗した宛先だけが再送されます
- リトライ中に設定を変更しても、進行中の配信には反映されません
- リトライが発生すると、あとに発生したイベントが先に届くことがあります。またタイムアウトしたリクエストが受信側では処理されていた場合、同じイベントが複数回届きます
受信側は、同じイベントを複数回受け取っても問題のない実装にしてください。
署名の検証
Orizm は Webhook の POST リクエストに Standard Webhooks 準拠の Ed25519 署名を付与します。受信側で署名を検証すると、リクエストが自分の登録したその Webhook 設定に由来することを暗号学的に確認できます。
署名ヘッダー
| ヘッダー | 説明 |
|---|---|
webhook-id | 配信ごとに一意な ID。リトライしても同じ値のため、受信側の冪等性キーとして使えます |
webhook-timestamp | 送信試行時刻(Unix 秒)。リトライごとに新しい値になります |
webhook-signature | v1a,<base64> 形式の署名。スペース区切りで複数の署名が併記されることがあります |
署名ヘッダーはカスタムリクエストヘッダーでは上書きできません。
公開鍵の取得
検証に使う公開鍵は、Webhook ごとの JWKS エンドポイントで配布されます。URL は Orizm コンソールの Webhook 詳細画面に表示されます。
鍵は自動的にローテーションされます。公開鍵をコードや設定ファイルにコピーして固定せず、必ず JWKS エンドポイントから取得してください。
- JWKS のレスポンスは 5 分〜24 時間を目安にキャッシュしてください
- 手持ちのどの鍵でも検証に失敗したときは、JWKS を再取得してから一度だけ再試行してください(鍵のローテーション直後に古いキャッシュを使っているケースに対応できます)。再取得には 30 秒程度の最短間隔を設けてください
- ローテーションの移行中は、新旧 2 つの鍵による 2 つの署名が届きます。署名が 1 つである前提の実装は、ローテーションのたびに壊れます
検証の手順
webhook-timestampが現在時刻から許容窓(推奨 ±5 分)以内であることを確認します。超えていたら拒否します(リプレイ対策)webhook-id、webhook-timestamp、リクエストボディを.で連結した文字列{webhook-id}.{webhook-timestamp}.{body}を署名対象としますwebhook-signatureをスペースで分割し、v1a,に続く base64 の署名のいずれかが、JWKS 内のいずれかの鍵で Ed25519 検証に通れば成功です
署名は JSON パース前の raw body に対して行われます。パースした値を再シリアライズするとバイト列が一致しないことがあるため、必ず受信したそのままのボディ文字列で検証してください。
レスポンスコードの指針
| 状況 | 返すべきステータス |
|---|---|
| 署名の検証の結果、リクエストが不正 | 401(Orizm は 4xx に対して再送しません) |
| JWKS URL が誤っているなど設定に起因する | 4xx(再送しても解決しません) |
| JWKS が取得できないなどの一時障害 | 5xx(Orizm が再送します) |
SDK で検証する
@orizm/cms-sdk/webhook または @orizm/consumer-sdk/webhook の createWebhookVerifier を使うと、JWKS の取得とキャッシュ、複数署名のパース、タイムスタンプの検証までをまとめて行えます。Web Crypto API と fetch だけで実装されているため、Cloudflare Workers や Vercel Edge などの Node.js 以外のランタイムでも動作します。
verifyRequest に受信した Request を渡すと、raw body の取り出しから検証、ペイロードのパースまでを行います。検証に成功した場合だけ body からペイロードを取得できます。ペイロードの型 WebhookPayload は orizm codegen consumer が生成します。
import type { WebhookPayload } from "@repo/orizm/consumer";
import { createWebhookVerifier } from "@orizm/consumer-sdk/webhook";
// リクエストごとに作り直すと JWKS のキャッシュが効かないため、モジュールスコープに置く
const verifier = createWebhookVerifier<WebhookPayload>({
jwksUrl: "<コンソールに表示される JWKS URL>",
});
export const POST = async (request: Request): Promise<Response> => {
const result = await verifier.verifyRequest(request);
if (!result.valid) {
return Response.json({ error: result.reason }, { status: 401 });
}
// result.body は検証済みのペイロード
console.log(result.body.event, result.body.table);
return Response.json({ ok: true });
};verifyRequest は署名検証のために body を読み切るため、呼び出したあとに request.json() や request.text() を呼ぶことはできません。ペイロードは result.body から取得してください。
JWKS が取得できないときは WebhookJwksFetchError が throw されます。署名の真偽を判定できていない一時障害なので、そのまま throw させてフレームワークに 5xx を返させるのが適切です(Orizm が再送します)。捕捉して独自のレスポンスを返す場合も、401 ではなく 5xx を返してください。
ただし JWKS URL が 404 を返した場合は、URL の設定間違いであって再送では解決しないため、throw ではなく reason が jwks-not-found の検証失敗として返ります。result.reason をログに出しておくと、署名が不正だったのか URL の設定が誤っているのかを切り分けられます。
JWKS の取得をランタイムのキャッシュに載せる
verifier は取得した JWKS をメモリ上にキャッシュしますが、サーバーレス環境ではインスタンスが使い捨てられるためキャッシュが効かないことがあります。fetchOptions に渡した値は JWKS 取得の fetch にそのまま渡るので、Next.js の Data Cache などに載せられます。
const verifier = createWebhookVerifier<WebhookPayload>({
jwksUrl: "<コンソールに表示される JWKS URL>",
fetchOptions: { next: { revalidate: 300 } },
});鍵は自動ローテーションされるため、cache: "force-cache" や next: { revalidate: false } のような無期限キャッシュは指定しないでください。JWKS が更新されず、ローテーション後に検証が通らなくなります。
Express や素の node:http のように Request を組み立てられない環境では、raw body 文字列とヘッダーを渡す verifyPayload を使います。
// express.raw({ type: "application/json" }) などで生のボディを受け取っておく
const result = await verifier.verifyPayload(
req.body.toString("utf8"),
req.headers,
);SDK を使わずに検証する
依存を追加せず、Web Crypto API だけで検証する例です。
export async function verifyWebhook(
jwksUrl: string,
payload: string,
headers: Headers,
): Promise<boolean> {
const id = headers.get("webhook-id");
const timestamp = headers.get("webhook-timestamp");
const signatureHeader = headers.get("webhook-signature");
if (id === null || timestamp === null || signatureHeader === null) {
return false;
}
// リプレイ対策: 許容窓 ±5 分
const skew = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(skew) || skew > 300) {
return false;
}
// 実際には 5 分〜24 時間を目安にキャッシュする
const jwks = await fetch(jwksUrl).then((res) => res.json());
const publicKeys = await Promise.all(
jwks.keys.map((jwk) =>
crypto.subtle.importKey("jwk", jwk, "Ed25519", false, ["verify"]),
),
);
const content = new TextEncoder().encode(`${id}.${timestamp}.${payload}`);
const signatures = signatureHeader
.split(" ")
.filter((entry) => entry.startsWith("v1a,"))
.map((entry) =>
Uint8Array.from(atob(entry.slice(4)), (char) => char.charCodeAt(0)),
);
for (const key of publicKeys) {
for (const signature of signatures) {
if (await crypto.subtle.verify("Ed25519", key, signature, content)) {
return true;
}
}
}
return false;
}呼び出し側では、JSON パース前の raw body(例: Next.js の Route Handler では await request.text()、Express では express.raw() や express.json({ verify }) で保持した生のボディ)を payload に渡してください。