CRM 的 Webhook 看起來是簡單的那一種。沒有金流,就算處理得慢,也不會有訂閱被刪掉。聯絡人改了,就去更新你那份副本。
然後有位客戶告訴你,同一筆資料的生命週期階段,在你的應用程式裡顯示 Lead,在 HubSpot 裡卻是 Customer,而且已經錯了兩週,相關的事件沒有一個被處理兩次。
業務資料的 Webhook,失敗的方式和付款 Webhook 不一樣。流量以更新為主,而不是以事件為主;同一個物件會在短時間內改很多次;順序保證也比任何讀過 payload 的人以為的還要弱。
重點摘要
- 「HubSpot 不保證你會依發生順序收到這些通知」
- 通知以陣列送達,而不是單一物件。一個批次最多可以有 100 則。
- 回應時限是 5 秒,而且是整個批次共用。其他發送端的時限,見 Webhook 服務商參考資料。
- 失敗的通知會在 24 小時內重試最多 10 次,這會拉長順序錯亂的時間窗。
- 文件寫明
eventId「不保證唯一」,所以它不適合當冪等鍵。 - 用
occurredAt排序,用複合鍵去重,永遠不要用抵達時間排序。
看起來像答案的欄位
從 Stripe 過來的人,反射動作是找出事件 ID,加上唯一索引,然後收工。HubSpot 的 payload 裡,eventId 就擺在那裡。
文件是這樣寫的:eventId 是「觸發這則通知的事件 ID。這個值不保證唯一。」
這是異常直白的警告,卻很容易一眼帶過。在 eventId 上建唯一索引,測試時看起來都對,但在正式環境的某個時間點,它不是拒絕一個你需要的事件,就是接受一個你已經處理過的事件。兩種失敗都不會主動現身。
你可以依賴的兩個欄位是 occurredAt,「這個事件發生的時間,以毫秒時間戳記表示」,以及 attemptNumber,「從 0 開始,表示這是第幾次嘗試通知你的服務這個事件」。
attemptNumber 是好用的診斷依據。只要大於 0,就代表這是重試,也就代表你的端點之前失敗過,也就代表事件現在抵達的順序,和它們發生的順序不一樣了。
批次改變了處理程式的形狀
HubSpot 不會一個請求只送一則通知:「你應該預期在單一請求中收到一個物件陣列。批次大小不固定,但會少於 100 則通知。」
這帶來三個後果。
你的處理程式是一個迴圈,不是一個函式。5 秒的時限涵蓋整個陣列。光是每個項目一次 30 毫秒的資料庫往返,大約 160 個項目就會超過時限;如果每個項目還附帶其他工作,陣列還不到 100 個就可能超過。
HTTP 沒有辦法表達部分失敗。如果 90 個項目中的第 40 個拋出例外,你沒辦法告訴 HubSpot「前 39 個已經寫入」。整個批次你只能回一個狀態碼。回非 2xx,90 個會在重試時全部回來;回 200,你還沒處理到的那 50 個就沒了。
這逼出和其他地方一樣的結論:先儲存整個陣列,回 200,之後再處理。批次寫入是一次 INSERT,而不是 90 次,這也順便解決了時間的問題。
export async function POST(req: Request) {
const raw = await req.text();
if (!verifyHubSpotSignature(raw, req.headers)) {
return new Response("unauthorized", { status: 401 });
}
const notifications = JSON.parse(raw); // always an array
await db.insertBatch(notifications); // one durable write
return new Response(null, { status: 200 }); // inside 5s regardless of size
}
為什麼順序會錯亂,又為什麼去重救不了
一位業務在九十秒內把一筆交易改了四次,產生四則通知。你的端點短暫異常,拒絕了第二則。HubSpot 會重試它,「最多 10 次」,而且這些重試「分散在接下來的 24 小時內,每次請求之間的延遲不一」。
於是第二則通知在第三、第四則之後才抵達。你的處理程式套用了它。這筆交易的階段,現在變回第二次修改時的狀態,而且會一直維持,直到有人再次修改這筆紀錄。
沒有一則通知被處理兩次,去重也運作得很完美,資料還是錯的。
真正重要的區別在這裡:冪等性防的是同一個事件被處理兩次,卻完全防不了用較舊的狀態覆蓋較新的狀態。這是兩種不同的失敗,解法也不同;而 CRM 的事件流產生第二種失敗的頻率,遠高於付款的事件流,因為同一個物件會被反覆更新。
解法是在寫入本身加上單調遞增的判斷。
UPDATE contacts
SET lifecycle_stage = $1, hs_updated_at = $2
WHERE hubspot_id = $3
AND hs_updated_at < $2; -- refuse to apply stale state
$2 要用 occurredAt,不要用你自己的 NOW()。你的時鐘記錄的是你收到的時間,而收到的順序,正是不可靠的那個東西。更完整的說明,包括狀態機有真正的不變條件時該怎麼做,見同一個 Webhook 收到兩次。
真正站得住的去重鍵
既然 eventId 不能用,就用幾個合起來能識別一次變更的欄位,組成複合鍵:
CREATE TABLE processed_hubspot (
object_id BIGINT NOT NULL,
subscription_type TEXT NOT NULL,
occurred_at BIGINT NOT NULL,
PRIMARY KEY (object_id, subscription_type, occurred_at)
);
一個物件、一種變更、一毫秒。同一個欄位在同一毫秒內兩次確實不同的修改會撞在一起;對人工操作的 CRM 編輯來說,這是真實但可以忽略的風險,對大量匯入來說,則是真實的風險。如果是屬性變更的訂閱,把 propertyName 加進鍵裡:不管改的是哪個屬性,subscriptionType 都一樣,少了它,同一毫秒內兩個不同屬性的變更也會撞在一起。
這點值得明說,因為它比 Stripe 給你的差了一級。有服務商發的唯一事件 ID,去重是精確的;用複合鍵,則是非常好的經驗法則。把這個選擇寫在資料表定義旁邊,讓下一個人知道這是刻意的。
大量匯入就是壓力測試
一位客戶匯入 50,000 筆聯絡人,會在短時間內產生 50,000 則通知,並打包成陣列送來。這時,上面說的一切同時不再只是理論:批次最大、資料庫最忙、5 秒的時限最緊,而且任何失敗都會產生重試,在幾小時後和即時的編輯交錯抵達。
如果你只用手做的單一事件測試,你根本沒測到會出問題的情況。給自己送一個 100 則的批次,故意把時間戳記打亂,檢查最終狀態是 occurredAt 最新的那則,而不是最後抵達的那則。
AnyHook 的角色
順序問題完全不是傳輸層的問題。如果 HubSpot 在第四則通知之後才送出第二則,沒有任何 Webhook 中繼服務(webhook relay)能把它收回,任何聲稱可以的產品都是在誇大。
中繼服務改變的,是它周圍的一切。
把 HubSpot 指向 in.anyhook.net/you/hubspot,不論批次多大,AnyHook 都大約一秒就回 200,5 秒的時限就不再限制你怎麼處理。傳遞到你端點的重試照 AnyHook 自己的排程,而不是 HubSpot 的,所以短暫的服務中斷,不會讓順序錯亂的時間窗拉長 24 小時。
除此之外:
- 每個批次在嘗試傳遞之前,都會連同標頭和請求主體一起儲存,所以「我們到底收到了什麼、依什麼順序」可以從事件紀錄回答,而不必從資料庫目前的狀態去推回來。
- 重播一段時間範圍內所有失敗的事件,就能拿修好的處理程式重跑匯入時遺失的部分,而不必請客戶重新匯入。
attemptNumber大於 0 不再是你察覺出事的唯一線索,因為傳遞失敗會被計數,並在 1、5、20 次時發出警示。
你的處理程式還是需要版本判斷。那部分是你的責任。
結論
CRM 這一類需要的反射動作,和金流那一類不同。建任何索引之前,先讀欄位文件,因為 HubSpot 異常坦白地告訴你,它的事件 ID 承擔不了你想加在它身上的重量。用 occurredAt 排序,用刻意選定的複合鍵去重,並且在每次寫入時防止過期狀態。
如果你的處理程式現在執行的是不帶時間戳記條件的 UPDATE ... WHERE id = $1,問題不在於它是否已經寫入過期資料,而在於有沒有人注意到。