Webhook 送信は「POST するだけ」ではない
自分のSaaSにWebhookを実装するとします。イベントが起きたら、顧客が登録したURLに JSONをPOSTする——コードにすれば10行です。実際、最初のバージョンはそれで動きます。
問題はその後に来ます。顧客のサーバーが落ちていたら? 失敗したイベントを顧客が「再送してほしい」と言ってきたら? 「そちらから本当に送られたリクエストか検証したい」と言われたら? 顧客が10社、100社と増えて、それぞれがエンドポイントの追加・削除・ログ確認を サポート経由で依頼してきたら?
Webhook送信のコストの大半は、POSTの周辺にあります。 リトライ、署名、エンドポイント管理、配信の可視化。Svix はこの周辺を丸ごと引き受ける「Webhook送信のためのインフラ」で、 Brex、Clerk、Twilio、PagerDuty、Resend、Replicate など多くのSaaSが 自社Webhookの配信基盤として採用しています。
本記事では、Svixが何をどう解決するのかを署名・リトライ・冪等性・運用UIの4点から見ていきます。 受信側として「svix-signature ヘッダーの付いたWebhookを検証したい」人にも、 送信側として「自前実装とどちらにすべきか」を考えている人にも効く内容です。
Svix とは何か — 3つの概念だけのデータモデル
Svixのデータモデルは3つしかありません。
| 概念 | 意味 | 対応するもの |
|---|---|---|
Application | メッセージの送信先単位 | 顧客ごとに1つ作るのが基本 |
Endpoint | 実際のWebhook URL | 1つのApplicationに複数ぶら下がる |
Message | 送信するイベント本体 | イベントタイプ + JSONペイロード |
送信側のコードは、イベントが起きたときに message.create を1回呼ぶだけです。 どのEndpointに配るか、失敗したらいつ再送するか、署名をどう付けるかはすべてSvix側の仕事になります。
import { Svix } from "svix";
const svix = new Svix("AUTH_TOKEN");
// 顧客 example-customer-123 に「請求書が支払われた」イベントを送る
await svix.message.create("example-customer-123", {
eventType: "invoice.paid",
eventId: "evt_Wqb1k73rXprtTm7Qdlr38G", // 冪等性のための一意ID
payload: {
type: "invoice.paid",
id: "invoice_WF7WtCLFFtd8ubcTgboSFNql",
status: "paid",
},
});graph LR A["自分のSaaS"] -->|"message.create<br/>1回呼ぶだけ"| B["Svix"] B -->|"署名付きPOST<br/>+ 自動リトライ"| C["顧客AのEndpoint"] B -->|"署名付きPOST<br/>+ 自動リトライ"| D["顧客BのEndpoint x2"] B -.->|"App Portal<br/>(自己管理UI)"| E["顧客自身"]
署名検証 — HMAC-SHA256 と3つのヘッダー
Webhookの受信エンドポイントは、インターネットに公開されたURLです。 署名検証をしなければ、誰でも偽のイベントをPOSTできてしまいます。 「invoice.paid が来たら機能を有効化する」実装なら、これは支払いのバイパスと同義です。
Svixが送るリクエストには3つのヘッダーが付きます。
| ヘッダー | 中身 |
|---|---|
svix-id | メッセージの一意ID。リトライでも変わらない |
svix-timestamp | 送信時刻(エポック秒) |
svix-signature | Base64エンコードされた署名。スペース区切りで複数入ることがある |
署名の対象は、この svix-id・svix-timestamp・生のリクエストボディを ピリオドで連結した文字列です。鍵は whsec_ プレフィックス付きのシークレットから、 プレフィックス後の部分をBase64デコードしたバイト列を使います。
const crypto = require('crypto');
// whsec_ の後ろを Base64 デコードして鍵にする
const secret = "whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH";
const secretBytes = Buffer.from(secret.split('_')[1], "base64");
// id . timestamp . 生ボディ を連結して HMAC-SHA256
const signedContent = `${svixId}.${svixTimestamp}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secretBytes)
.update(signedContent)
.digest('base64');
// 比較は必ず定時間比較で(タイミング攻撃対策)
const ok = crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(receivedSignature),
);とはいえ手動実装には落とし穴が多いので、実務では公式ライブラリのWebhook.verify() を使うのが正解です。 検証・タイムスタンプ確認・複数署名の照合をまとめてやってくれます。
import { Webhook } from "svix";
const wh = new Webhook(process.env.WEBHOOK_SECRET);
// verify は検証失敗時に例外を投げる。成功すればパース済みペイロードが返る
const payload = wh.verify(rawBody, {
"svix-id": req.headers["svix-id"],
"svix-timestamp": req.headers["svix-timestamp"],
"svix-signature": req.headers["svix-signature"],
});リプレイ攻撃は「5分」で切る
署名が正しくても、攻撃者が過去の正規リクエストを丸ごと再送することはできます (リプレイ攻撃)。対策として svix-timestamp も署名対象に含まれており、 Svixの公式ライブラリは現在時刻から5分以上ずれた(過去・未来とも)Webhookを自動的に拒否します。 タイムスタンプ自体が署名されているので、改ざんして期限を延ばすこともできません。
リトライと冪等性 — 送信側と受信側の契約
指数バックオフで約38時間、8回
配信先が2xx以外を返した(または応答しなかった)場合、Svixは指数バックオフでリトライします。
| 試行 | 前回失敗からの間隔 |
|---|---|
| 1回目 | 即時 |
| 2回目 | 5秒後 |
| 3回目 | 5分後 |
| 4回目 | 30分後 |
| 5回目 | 2時間後 |
| 6回目 | 5時間後 |
| 7回目 | 10時間後 |
| 8回目 | さらに10時間後 |
合計で約38時間。それでも届かなければメッセージは Failed になり、 送信側には message.attempt.exhausted という運用Webhookが通知されます。 さらに、あるEndpointへの試行が5日間すべて失敗し続けると、そのEndpointは自動的に無効化されます。 死んだURLに永遠に投げ続けない、という設計です。
受信側の義務: svix-id を冪等キーにする
リトライがある以上、配信は at-least-once(少なくとも1回)です。 つまり同じイベントが2回届くことは仕様上あり得ます。 タイムアウトと判定されたが実は処理されていた、というケースが典型です。
ここで効くのが「svix-id はリトライでも変わらない」という保証です。 受信側は処理済みの svix-id を記録しておき、 既知のIDが来たら処理をスキップして200を返す——これで二重処理を防げます。
const svixId = req.headers["svix-id"];
// 処理済みならスキップ(ただし 200 を返すこと。エラーを返すとまたリトライされる)
const seen = await redis.set(`webhook:${svixId}`, "1", { nx: true, ex: 86400 });
if (!seen) {
return new Response("already processed", { status: 200 });
}
await handleEvent(payload);App Portal — 「エンドポイント管理」を顧客に返す
地味に効くのがここです。Webhookを提供すると、 「エンドポイントを追加したい」「配信ログを見たい」「失敗したイベントを再送してほしい」 という依頼が顧客からサポートに流れ込みます。
Svixの App Portal は、この管理画面を顧客自身に開放するUIです。 API呼び出し1回で短命のマジックリンクを発行でき、顧客はSvixのアカウントなしで 自分のEndpointの追加・削除、配信ログの閲覧、個別メッセージの手動リトライ、 失敗分の一括再送(Recover Failed)までを自己完結できます。 自前でWebhook管理画面を作る工数が丸ごと消える部分で、 「配信エンジンだけならOSSで書けるが、ここまで作るのは重い」という分岐点になりがちです。
Standard Webhooks と自前実装との分岐点
Svixの署名スキームは、同社が主導するオープン仕様Standard Webhooks の土台になっています。 Webhookの署名・ヘッダー・リトライの慣行をベンダー中立に標準化する試みで、svix-signature と同じ計算方式が webhook-signature として仕様化されています。 つまりSvixの検証方法を一度理解すれば、同仕様に準拠する他サービスのWebhookもほぼ同じコードで検証できます。 また、Svix自体もサーバーのオープンソース版が公開されており、セルフホストという選択肢もあります。
では自前実装とどちらを選ぶべきか。判断材料を並べます。
| 観点 | 自前実装 | Svix |
|---|---|---|
| 最初のPOST | 10行で書ける | SDK導入が必要 |
| リトライ | キュー + バックオフを自分で設計 | 組み込み(8回・約38時間) |
| 署名 | HMAC実装 + シークレットローテーション設計 | 組み込み(Standard Webhooks準拠) |
| 顧客向け管理UI | ログ画面・再送UIを自作 | App Portal(API1回で発行) |
| 死んだEndpoint対策 | 無効化ロジックを自作 | 5日間失敗で自動無効化 |
| コスト | インフラ + 開発・運用工数 | 従量課金(無料枠あり) |
目安としては、Webhookが製品の付加機能で、受信者が数件〜十数件のうちは自前でも回ります。 顧客ごとにEndpointが増え、再送依頼やログ確認がサポートチケットになり始めたら、 それはPOSTの問題ではなく運用の問題なので、SvixのようなマネージドかOSS版の導入を検討する段階です。
まとめ
Svixが売っているのは「POSTの代行」ではなく、Webhookをめぐる送信側と受信側の契約一式です。 署名はこう計算する、リトライはこの間隔で来る、同じイベントは同じIDで再送される、 だから受信側はIDで冪等にする——この契約が仕様とライブラリの両方で揃っているから、 送る側も受ける側も迷いなく実装できます。
受信側としては、今日から使える結論は3つです。生のボディで検証する、svix-id で冪等にする、重い処理はキューに逃がして即200を返す。 この3つはSvixに限らず、あらゆるWebhook受信の定石でもあります。
参考リンク
- Webhook Security — Svix Docs(署名スキームとリプレイ攻撃対策の一次情報)
- Verifying Webhooks Manually — Svix Docs(HMAC-SHA256の手動検証手順。本記事のコード例の出典)
- Retries — Svix Docs(リトライスケジュール、Endpoint自動無効化、Recover Failed)
- Quickstart — Svix Docs(Application / Endpoint / Message のデータモデルと送信コード例)
- Standard Webhooks(Svixの署名スキームを土台にしたベンダー中立のWebhook仕様)
- Guide to Svix Webhooks — Hookdeck(受信側から見たSvixの実装ガイド)
理解度チェック
Svixの署名対象は「svix-id、svix-____、生のリクエストボディ」をピリオドで連結した文字列です。空欄に入る単語を答えてください。