Webhook の冪等性について書かれたものの多くは、このブログの記事も含めて、データベースを起点に論じています。重複した行、2 通目の領収書、2 倍になったカウンターといった問題です。どれも本物の問題ですが、抽象的です。読み手はうなずいて、次へ進みます。
予約の連携では、この議論は抽象的ではなくなります。重複は行ではありません。10:00 のビデオ通話に、どちらも自分の枠だと思っている 2 人がいるのです。しかも 1 人は、そのために午前休を取っています。
TL;DR
- 予約の Webhook はタイムアウトが緩いので、安全だと感じがちです
- リトライの結果は、片づけられる行ではなく、人が解決しなければならない現実の衝突です
- Calendly は
Calendly-Webhook-Signatureで署名します。タイムスタンプと署名の組で、timestamp.bodyに対する HMAC-SHA256 です - 重複排除はペイロードの内容ではなく、予約そのものの URI か UUID で行います。日程変更は新規予約とよく似て見えるからです
- 実際にカレンダーを壊すのは、キャンセルや日程変更が順不同で届くケースです
このカテゴリーが安全に見えて、実はそうでない理由
ほかのカテゴリーの Webhook を怖くしているものの多くが、ここにはありません。Shopify のような 5 秒の応答期限はありません。悪い午後を過ごしても、サブスクリプションが削除されることはありません。ほとんどの製品では予約の量が少なく、レート制限に引っかかることもありません。
そのため、ハンドラーは手早く書かれ、1 件の予約でテストされ、そのままリリースされます。
リスクは反対側にあります。決済の Webhook が 2 回処理されたら、自分のデータで気づき、外部の誰かに影響が出る前に直せます。予約の Webhook が 2 回処理されると、2 つ目のコピーはすぐに顧客の目に触れます。気づいたときには、被害はデータの不整合ではなく、予定の衝突になっています。
具体的な流れ
招待者が 10:00 を予約します。Calendly は invitee.created を送ります。
ハンドラーは枠を書き込み、カレンダーのプロバイダーを呼んで予定を作成し、確認メールを送ります。その朝はカレンダーの呼び出しが遅く、全体に時間がかかりすぎて、配信は失敗として記録されます。
Calendly はリトライします。ハンドラーがもう一度動きます。1 回目の実行が完了したことは、ハンドラーのどこにも伝わっていません。1 回目が完了したのは、レスポンスがすでに失われた後だったからです。カレンダーの予定が 2 件、確認メールが 2 通、そして空き状況のロジックが二重に埋まっていると判断する枠が残ります。
個々のステップはどれも正しく動きました。ハンドラーには、ユニットテストで見つかるようなバグはありません。
ペイロードではなく予約で重複排除する
まず思いつくのは、ペイロードをハッシュし、ハッシュが一致したものを重複とみなす方法です。
プロバイダーが予約そのものに割り当てる識別子を使ってください。Calendly のペイロードには、招待者と予定されたイベントを一意に特定する URI が含まれています。Cal.com や Acuity にも同等のものがあります。この識別子は、同じ配信のリトライ間では変わらず、本当に別の予約なら異なります。
CREATE TABLE processed_bookings (
booking_uri TEXT NOT NULL,
event_type TEXT NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (booking_uri, event_type)
);
const { rowCount } = await db.query(
`INSERT INTO processed_bookings (booking_uri, event_type, occurred_at)
VALUES ($1, $2, $3) ON CONFLICT (booking_uri, event_type) DO NOTHING`,
[body.payload.uri, body.event, body.created_at],
);
if (rowCount === 0) return new Response(null, { status: 200 });
URI 単体ではなく、(booking_uri, event_type) の複合キーにしている点に注意してください。同じ予約から、正当に invitee.created が生まれ、後で invitee.canceled が生まれます。必要なのは、その両方をちょうど 1 回ずつ処理することです。
順不同のキャンセルはもっと悪いバグ
重複は、誰かがすぐに気づく衝突を生みます。順序の失敗は、静かに間違ったカレンダーを生みます。
誰かが 10:00 を予約し、4 分後にキャンセルします。invitee.created の配信は失敗し、リトライされます。invitee.canceled の配信が先に成功します。ハンドラーはまだ存在しない予約をキャンセルし、警告をログに残して次へ進みます。その後リトライが届き、予約が作成されます。
これで枠は、誰も来ない会議のために埋まったままになります。どこにもエラーは出ていません。重複排除は関係ありません。どちらのイベントも 2 回は届いていません。間違った順序で届いたのです。
対処は、サブスクリプションや CRM のレコードで使うのと同じ単調増加のガードです。到着時刻ではなく、予約自体のタイムスタンプに適用します。
UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
AND updated_at < $2;
これほど小さなステートマシンなら、明示的な遷移チェックのほうがさらに良い方法です。すでに canceled の予約は、遅れて届いた作成イベントで active になることを拒否すべきです。そしてその拒否は、黙ってではなく、目立つようにログに残すべきです。一般的な扱い方は「同じ Webhook が 2 回届いた」で説明しています。
署名検証
Calendly は、タイムスタンプと署名をカンマで区切った Calendly-Webhook-Signature ヘッダーを送ります。署名対象のペイロードは、タイムスタンプ、ドット、生のリクエストボディをつなげたものです。それを、サブスクリプション作成時に渡された署名キーを使って HMAC-SHA256 でハッシュします。
この形に見覚えがあるなら、それは Stripe と同じ構成だからです(どのプロバイダーが同じ構成を使っているかは Webhook プロバイダー別リファレンスで確認できます)。要件も同じく 2 つあります。再シリアライズしたオブジェクトではなく生のバイト列をハッシュすること、そして定数時間で比較することです。プロバイダーごとの詳細は「Stripe、GitHub、Shopify の Webhook 署名を検証する」にあります。
署名ヘッダーのないリクエストは、既定で許可するのではなく拒否してください。署名のない POST を受け付ける予約エンドポイントは、インターネット上の誰にでもカレンダーを埋めさせてしまいます。
カレンダーそのものを制約にする
このカテゴリーで最も頼りになる対処は、Webhook ハンドラーの中にはありません。リソースのレベルでの一意性制約です。
CREATE UNIQUE INDEX one_booking_per_slot
ON bookings (resource_id, starts_at)
WHERE status = 'active';
これで、ほかのすべての層が失敗しても、重複した配信がダブルブッキングを生むことはありません。データベースが拒否するからです。ハンドラーは制約違反を捕捉し、エラーではなく重複として扱います。
冪等性を正しく実装していても、これはやる価値があります。保証の根拠が「ハンドラーが注意深い」から「スキーマ上ありえない」に変わるからです。この 2 つは強さが違います。顧客が直接体験するものには、後者が必要です。
AnyHook の位置づけ
リトライは通信路の振る舞いです。ハンドラーが同じものを 2 回見たのは、最初の配信に時間内に応答できなかったからです。
Webhook のサブスクリプションの送信先を in.anyhook.net/you/scheduling にします。AnyHook はエッジで永続化し、約 1 秒で応答します。そのため、受信側のカレンダー API が遅くても配信の失敗にはならず、プロバイダーのリトライは発生しません。あの重複がハンドラーに届くこともありません。
- エンドポイントへの配信は指数バックオフでリトライされます。間隔が空くので、復旧中のシステムは圧力ではなく余裕を得られます
- 予約のイベントはすべて配信前に保存されます。「キャンセルは届いたのか」に、間違っているかもしれないカレンダーから推測するのではなく、ログから答えられます
- 時間帯を指定した再送で、壊れていた時間帯を修正済みのハンドラーに対して流し直せます。予約の場合、顧客に予約し直しをお願いするメールを送るのではなく、カレンダーを直せるということです
- 連続失敗が 1 回、5 回、20 回に達したときに失敗アラートが送られます。このカテゴリーでは、監視で気づくか、来てしまった人から知らされるかの違いになります
それでもハンドラーには、重複排除のキー、バージョンガード、できればユニークインデックスが必要です。中継サービスが取り除くのはプロバイダーのリトライであって、リトライが起きたときに正しく動く必要性ではありません。
まとめ
予約は、Webhook の正しさがエンジニアリングの関心事から、顧客の目に触れる問題に変わるカテゴリーです。タイムアウトが緩いので、2 人が同じ枠に現れるその瞬間まで、それを考えるよう迫ってくるものは何もありません。
重複排除は、予約の識別子とイベントタイプを組み合わせて行います。書き込みは、自分のタイムスタンプではなくプロバイダーのタイムスタンプで守ります。枠にユニークインデックスを張り、ハンドラーが見逃したものをスキーマで捕まえます。そして、遅れて届いたキャンセルは、遅れて届いた返金と同じように扱ってください。静かに間違ったカレンダーは、静かに間違った行よりも直すのに高くつくからです。