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 URL1つの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["顧客自身"]
送信側はイベントを渡すだけ。配信・リトライ・署名・顧客向けUIはSvixが担う

署名検証 — HMAC-SHA256 と3つのヘッダー

Webhookの受信エンドポイントは、インターネットに公開されたURLです。 署名検証をしなければ、誰でも偽のイベントをPOSTできてしまいます。 「invoice.paid が来たら機能を有効化する」実装なら、これは支払いのバイパスと同義です。

Svixが送るリクエストには3つのヘッダーが付きます。

ヘッダー中身
svix-idメッセージの一意ID。リトライでも変わらない
svix-timestamp送信時刻(エポック秒)
svix-signatureBase64エンコードされた署名。スペース区切りで複数入ることがある

署名の対象は、この svix-idsvix-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
最初のPOST10行で書ける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受信の定石でもあります。

参考リンク

理解度チェック

問題 0 / 50%
Q1

Svixの署名対象は「svix-id、svix-____、生のリクエストボディ」をピリオドで連結した文字列です。空欄に入る単語を答えてください。