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

HubSpot 說它的 eventId 可能不唯一,那就相信它

HubSpot 不保證順序,文件也寫明 eventId 不保證唯一。你在 Stripe 的 event.id 上養成的直覺,在這裡全都失靈。

payload 給你的東西eventId不保證唯一occurredAt毫秒時間戳記attemptNumber0, 1, 2 …你第一個會想用的欄位,正是文件叫你別信任的那一個用 (objectId, subscriptionType, occurredAt) 去重 · 用 occurredAt 排序,絕不依抵達順序
payload 裡有三個欄位。你第一個想拿來用的,正是文件特別警告的那個。

CRM 的 Webhook 看起來是簡單的那一種。沒有金流,就算處理得慢,也不會有訂閱被刪掉。聯絡人改了,就去更新你那份副本。

然後有位客戶告訴你,同一筆資料的生命週期階段,在你的應用程式裡顯示 Lead,在 HubSpot 裡卻是 Customer,而且已經錯了兩週,相關的事件沒有一個被處理兩次。

業務資料的 Webhook,失敗的方式和付款 Webhook 不一樣。流量以更新為主,而不是以事件為主;同一個物件會在短時間內改很多次;順序保證也比任何讀過 payload 的人以為的還要弱。

重點摘要

  • 「HubSpot 不保證你會依發生順序收到這些通知」
  • 通知以陣列送達,而不是單一物件。一個批次最多可以有 100 則。
  • 回應時限是 5 秒,而且是整個批次共用。其他發送端的時限,見 Webhook 服務商參考資料。
  • 失敗的通知會在 24 小時內重試最多 10 次,這會拉長順序錯亂的時間窗。
  • 文件寫明 eventId「不保證唯一」,所以它不適合當冪等鍵。
  • 用 occurredAt 排序,用複合鍵去重,永遠不要用抵達時間排序。

看起來像答案的欄位

從 Stripe 過來的人,反射動作是找出事件 ID,加上唯一索引,然後收工。HubSpot 的 payload 裡,eventId 就擺在那裡。

文件是這樣寫的:eventId 是「觸發這則通知的事件 ID。這個值不保證唯一。」

payload 給你的東西eventId不保證唯一occurredAt毫秒時間戳記attemptNumber0, 1, 2 …你第一個會想用的欄位,正是文件叫你別信任的那一個用 (objectId, subscriptionType, occurredAt) 去重 · 用 occurredAt 排序,絕不依抵達順序

這是異常直白的警告,卻很容易一眼帶過。在 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,問題不在於它是否已經寫入過期資料,而在於有沒有人注意到。

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

別再漏掉 Webhook。

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