可靠性2026年8月6日閱讀時間 7 分鐘

重複預約:Webhook 重試在真人眼中是什麼樣子

談冪等性,通常只談到資料庫多一列。預約排程的 Webhook,結局卻是兩個人同時來赴 10:00 的約。

同一筆預約,傳遞了兩次invitee.created200 太慢你的處理程式寫入時段行事曆10:00 已被預約重試同一筆預約行事曆10:00 被預約兩次兩個人,一個時段衝突不在你的資料庫裡,而在某個人的早上。
一筆預約,兩次傳遞,兩筆行事曆項目。重複不在資料表裡,而在某個人的早上。

談 Webhook 冪等性的文章,包括我們自己寫的,多半從資料庫往外推論:多了一列重複資料、多寄一張收據、計數器多算一次。都是真實的問題,但很抽象。讀者點點頭,就往下滑了。

到了預約排程的整合,這個論點就不再抽象。重複的不是一列資料,而是 10:00 的視訊通話上,兩個都以為這個時段屬於自己的人,其中一個還為此請了一早上的假。

重點摘要

  • 預約排程的 Webhook 時限很寬鬆,讓人覺得很安全。
  • 重試的後果,是一個必須由人來解決的現實衝突,而不是一列你清掉就好的資料。
  • Calendly 用 Calendly-Webhook-Signature 簽章,內容是一組時間戳記和簽章,以 HMAC-SHA256 對 timestamp.body 計算。
  • 用預約本身的 URI 或 UUID 去重,不要用 payload 內容,因為改期看起來很像一筆新預約。
  • 取消和改期的通知順序錯亂,才是真正會把行事曆弄壞的情況。

為什麼這一類感覺安全,其實不然

讓其他類 Webhook 令人害怕的東西,這裡大多沒有。沒有像 Shopify 那樣五秒的回應時限,也不會因為一個糟糕的下午就被刪掉訂閱。對多數產品來說,預約量低到你永遠碰不到速率限制。

於是處理程式很快寫完,用一筆預約測過,就上線了。

風險在另一邊。付款 Webhook 被處理兩次,你會在自己的資料裡發現,並在任何外部的人受影響之前修好。預約 Webhook 被處理兩次,第二份馬上就會被客戶看到;等你發現時,損害已經是一場時間衝突,而不只是資料不一致。

同一筆預約,傳遞了兩次invitee.created200 太慢你的處理程式寫入時段行事曆10:00 已被預約重試同一筆預約行事曆10:00 被預約兩次兩個人,一個時段衝突不在你的資料庫裡,而在某個人的早上。

事情具體是怎麼發生的

一位受邀者預約了 10:00。Calendly 發出 invitee.created。

你的處理程式寫入這個時段,接著呼叫行事曆服務建立活動,再寄出確認信。那天早上,行事曆的呼叫很慢。整個流程久到這次傳遞被記為失敗。

Calendly 重試,處理程式再跑一次。它完全不知道第一次已經跑完,因為第一次完成時,回應早就丟了。兩筆行事曆項目、兩封確認信,還有一個你的可預約時段邏輯認為被重複佔用的時段。

每個步驟單獨看都正常。這個處理程式沒有任何單元測試抓得到的 bug。

依預約去重,不要依 payload

直覺的做法,是對 payload 算雜湊,雜湊相同就當成重複。

應該改用服務商指派給這筆預約本身的識別碼。Calendly 的 payload 帶有一個 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 });

注意這裡用的是 (booking_uri, event_type) 的複合鍵,而不是只有 URI。同一筆預約本來就會先產生 invitee.created,之後再產生 invitee.canceled,兩個你都要,而且各處理一次。

順序錯亂的取消,是更糟的 bug

重複會造成一個有人馬上察覺的衝突。順序錯亂則會造成一份悄悄出錯的行事曆。

有人預約了 10:00,四分鐘後又取消。invitee.created 的傳遞失敗,進入重試。invitee.canceled 的傳遞先成功了。你的處理程式去取消一筆還不存在的預約,記下一個警告,然後繼續。接著重試送達,建立了這筆預約。

這個時段現在被一場沒有人會來的會議佔住,而且任何地方都沒有報錯。去重在這裡無關緊要:兩個事件都沒有抵達兩次。它們抵達的順序錯了。

解法和訂閱、CRM 紀錄用的一樣,是單調遞增的判斷,只是套用在預約本身的時間戳記上,而不是你收到的時間:

UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
  AND updated_at < $2;

狀態機這麼小,明確檢查狀態轉換更好。已經是 canceled 的預約,遇到晚到的建立事件時,應該拒絕變成 active,而且這個拒絕要大聲記錄,不能默默發生。通用的做法見同一個 Webhook 收到兩次。

簽章驗證

Calendly 會送出 Calendly-Webhook-Signature 標頭,裡面是以逗號分隔的時間戳記和簽章。被簽章的內容是時間戳記、一個句點,再加上原始請求主體,用建立訂閱時拿到的簽章金鑰,以 HMAC-SHA256 計算雜湊。

如果這個形狀看起來很眼熟,那是因為它和 Stripe 用的構造相同(Webhook 服務商參考資料列出了哪些服務商共用這種做法),也帶著同樣兩個要求:對原始位元組計算雜湊,而不是對重新序列化的物件;並且用常數時間比較。各服務商的細節,見驗證 Stripe、GitHub 和 Shopify 的 Webhook 簽章。

缺少簽章標頭的請求要拒絕,不要預設放行。一個接受未簽章 POST 的預約端點,等於讓網路上任何人都能塞滿你的行事曆。

讓行事曆本身成為約束

這一類問題最站得住的解法,根本不在 Webhook 處理程式裡,而是資源層級的唯一性約束:

CREATE UNIQUE INDEX one_booking_per_slot
  ON bookings (resource_id, starts_at)
  WHERE status = 'active';

這樣一來,即使其他每一層都失靈,重複的傳遞也不可能造成重複預約,因為資料庫會拒絕它。處理程式捕捉到違反約束的錯誤,把它當成重複,而不是錯誤。

即使冪等已經做對,這仍然值得做,因為它把保證從「我們的處理程式很小心」,提升到「資料表結構讓它不可能發生」。這是兩種不同強度的保證,對於客戶會直接感受到的東西,你要的是第二種。

AnyHook 的角色

重試是傳輸層的行為,你的處理程式之所以看到兩次,是因為第一次傳遞沒能及時確認收到。

把 Webhook 訂閱指向 in.anyhook.net/you/scheduling。AnyHook 在邊緣節點儲存事件,大約一秒就確認收到,所以你這邊的行事曆 API 再慢,也不會造成傳遞失敗。也就是說,服務商的重試永遠不會觸發,那份重複也永遠到不了你的處理程式。

  • 傳遞到你端點的重試採用指數退避,間隔拉開,讓正在恢復的系統有喘息空間,而不是更大的壓力。
  • 每個預約事件都在傳遞前儲存,所以「我們有沒有收到取消通知」可以從事件紀錄回答,而不必從一份可能有錯的行事曆去推測。
  • 重播一段時間範圍,能用修好的處理程式重跑出問題的那段時間;對預約來說,這代表修好行事曆,而不是寫信請客戶重新預約。
  • 連續失敗 1、5、20 次時發出失敗警示。在這一類情境裡,這就是從監控得知問題,和從到場的人口中得知問題的差別。

你的處理程式仍然需要去重鍵、版本判斷,最好再加上唯一索引。Webhook 中繼服務(webhook relay)消除的是服務商的重試,而不是重試發生時你必須正確處理的要求。

結論

在預約排程這一類,Webhook 的正確性不再只是工程問題,而是客戶直接面對的問題。時限寬鬆到沒有任何東西逼你去想它,直到兩個人出現在同一個時段。

用預約識別碼和事件類型一起去重。寫入時用服務商的時間戳記把關,不要用你自己的。在時段上建唯一索引,讓資料表結構接住處理程式漏掉的東西。對待晚到的取消,要像對待晚到的退款一樣,因為悄悄出錯的行事曆,修起來比出錯的一列資料更花成本。

所有文章2026年8月6日 · 7 分鐘

別再漏掉 Webhook。

換一個網址,就有重試、事件紀錄和一鍵重播。或者先親眼看看:註冊後跑一次 30 秒的當機演練,看著一個事件在伺服器當機時保存下來。不需要伺服器。