Skip to Content
FeaturesWebhook概要

Webhook

Webhook機能を使用すると、コンテンツの作成、更新、削除が行われた際に、指定したURLに通知を送信できます。これにより、外部システムやサービスとの連携が可能です。

機能

  • コンテンツの作成、更新、削除を通知
  • テーブルごとに通知するイベントを指定
  • カスタムリクエストヘッダーに対応
  • 配信失敗時の自動リトライ
  • Ed25519 署名による送信元の検証

設定方法

Orizmコンソールのプロジェクト設定からWebhookを設定できます。

コマンドラインから設定する場合は webhook コマンド を参照してください。

プロジェクト設定を開く

プロジェクト設定を開く

Webhook設定を開く

Webhook設定を開く

Webhookの作成をクリック

Webhookの作成をクリック

Webhookの作成

Webhookの作成

プロパティ名概要
Nameコンソール上の表示用です
URL指定したURLにPOSTリクエストでイベントを通知します

テーブルとイベントを選択

Table Events で通知を受け取るテーブルとイベントを選択します。

  • Add Table Event をクリックするとテーブルを追加します
  • Add All Tables をクリックすると全てのテーブルを追加します

特定のイベント通知が不要な場合は、チェックボックスを解除することで通知を無効にできます。

Save をクリックすると設定が保存されます。

テーブルとイベントを選択

カスタムリクエストヘッダー

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

カスタム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/jsonUser-Agent: superx-webhook署名ヘッダーwebhook-idwebhook-timestampwebhook-signature)、および設定したカスタムリクエストヘッダー
タイムアウト5秒
成功の判定ステータスコードが2xx
リトライする失敗タイムアウト、ネットワークエラー、5xxのレスポンス
リトライしない失敗4xxのレスポンス
試行回数初回を含めて最大10回
リトライの間隔指数バックオフ(1秒から始まり、失敗するたびに倍になります)
  • 通知先が複数設定されている場合、宛先ごとに独立して配信とリトライを行います。失敗した宛先だけが再送されます
  • リトライ中に設定を変更しても、進行中の配信には反映されません
  • リトライが発生すると、あとに発生したイベントが先に届くことがあります。またタイムアウトしたリクエストが受信側では処理されていた場合、同じイベントが複数回届きます

受信側は、同じイベントを複数回受け取っても問題のない実装にしてください。

署名の検証

Orizm は Webhook の POST リクエストに Standard Webhooks  準拠の Ed25519 署名を付与します。受信側で署名を検証すると、リクエストが自分の登録したその Webhook 設定に由来することを暗号学的に確認できます。

署名ヘッダー

ヘッダー説明
webhook-id配信ごとに一意な ID。リトライしても同じ値のため、受信側の冪等性キーとして使えます
webhook-timestamp送信試行時刻(Unix 秒)。リトライごとに新しい値になります
webhook-signaturev1a,<base64> 形式の署名。スペース区切りで複数の署名が併記されることがあります

署名ヘッダーはカスタムリクエストヘッダーでは上書きできません。

公開鍵の取得

検証に使う公開鍵は、Webhook ごとの JWKS エンドポイントで配布されます。URL は Orizm コンソールの Webhook 詳細画面に表示されます。

鍵は自動的にローテーションされます。公開鍵をコードや設定ファイルにコピーして固定せず、必ず JWKS エンドポイントから取得してください。

  • JWKS のレスポンスは 5 分〜24 時間を目安にキャッシュしてください
  • 手持ちのどの鍵でも検証に失敗したときは、JWKS を再取得してから一度だけ再試行してください(鍵のローテーション直後に古いキャッシュを使っているケースに対応できます)。再取得には 30 秒程度の最短間隔を設けてください
  • ローテーションの移行中は、新旧 2 つの鍵による 2 つの署名が届きます。署名が 1 つである前提の実装は、ローテーションのたびに壊れます

検証の手順

  1. webhook-timestamp が現在時刻から許容窓(推奨 ±5 分)以内であることを確認します。超えていたら拒否します(リプレイ対策)
  2. webhook-idwebhook-timestamp、リクエストボディを . で連結した文字列 {webhook-id}.{webhook-timestamp}.{body} を署名対象とします
  3. 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/webhookcreateWebhookVerifier を使うと、JWKS の取得とキャッシュ、複数署名のパース、タイムスタンプの検証までをまとめて行えます。Web Crypto API と fetch だけで実装されているため、Cloudflare Workers や Vercel Edge などの Node.js 以外のランタイムでも動作します。

verifyRequest に受信した Request を渡すと、raw body の取り出しから検証、ペイロードのパースまでを行います。検証に成功した場合だけ body からペイロードを取得できます。ペイロードの型 WebhookPayloadorizm codegen consumer が生成します。

src/app/api/webhook/route.ts
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 ではなく reasonjwks-not-found の検証失敗として返ります。result.reason をログに出しておくと、署名が不正だったのか URL の設定が誤っているのかを切り分けられます。

JWKS の取得をランタイムのキャッシュに載せる

verifier は取得した JWKS をメモリ上にキャッシュしますが、サーバーレス環境ではインスタンスが使い捨てられるためキャッシュが効かないことがあります。fetchOptions に渡した値は JWKS 取得の fetch にそのまま渡るので、Next.js の Data Cache などに載せられます。

src/app/api/webhook/route.ts
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 を使います。

src/webhook.ts
// express.raw({ type: "application/json" }) などで生のボディを受け取っておく const result = await verifier.verifyPayload( req.body.toString("utf8"), req.headers, );

SDK を使わずに検証する

依存を追加せず、Web Crypto API だけで検証する例です。

verify-webhook.ts
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 に渡してください。

Last updated on