信頼性2026年8月6日8 分で読めます

HubSpot の eventId は一意とは限らない:その警告を信じる

HubSpot は順序を保証せず、eventId が一意である保証もないと明記しています。Stripe の event.id で培った直感は、ここでは全部外れます。

ペイロードに含まれるものeventId一意とは限らないoccurredAtミリ秒のタイムスタンプattemptNumber0, 1, 2 …真っ先に使いたくなるフィールドこそ、ドキュメントが信用するなと書いているもの(objectId, subscriptionType, occurredAt) で重複排除 · 順序は到着順ではなく occurredAt で決める
ペイロードにある 3 つのフィールド。真っ先に手が伸びる 1 つこそ、ドキュメントが警告しているもの。

CRM の Webhook は簡単な部類に見えます。お金は動かず、遅くてもサブスクリプションが削除されることはありません。コンタクトが変わったら、手元のコピーを更新するだけです。

ところが、顧客からこう言われます。アプリではライフサイクルステージが Lead なのに、HubSpot では Customer です。しかも 2 週間ずっと間違ったままで、関係するイベントに、2 回処理されたものはありませんでした。

業務レコードの Webhook は、決済の Webhook とは壊れ方が違います。量の大半は新しい出来事ではなく更新で、同じオブジェクトが短時間に何度も変わります。そして順序の保証は、ペイロードを読んだ誰もが思うより弱いのです。

TL;DR

  • 「HubSpot は、これらの通知を発生した順に受け取れることを保証しません」
  • 通知は単一のオブジェクトではなく、配列で届きます。バッチには最大 100 件が入ります
  • 応答期限は、バッチ全体で 5 秒です。ほかの送信元については Webhook プロバイダー別リファレンスを参照してください
  • 失敗した通知は 24 時間かけて最大 10 回リトライされ、順序が入れ替わる時間幅を広げます
  • eventId はドキュメントで「一意であることは保証されない」とされているので、冪等キーには不適切です
  • 順序は occurredAt で決めます。重複排除には複合キーを使います。到着時刻で並べてはいけません

答えに見えるフィールド

Stripe から来た人は、反射的にイベント ID を探し、ユニークインデックスを張って次に進みます。HubSpot のペイロードにも、すぐそこに eventId があります。

ドキュメントにはこうあります。eventId は「この通知をトリガーしたイベントの ID です。この値は一意であることが保証されていません。」

ペイロードに含まれるものeventId一意とは限らないoccurredAtミリ秒のタイムスタンプattemptNumber0, 1, 2 …真っ先に使いたくなるフィールドこそ、ドキュメントが信用するなと書いているもの(objectId, subscriptionType, occurredAt) で重複排除 · 順序は到着順ではなく occurredAt で決める

これは珍しいほど率直な警告ですが、読み飛ばしやすいものです。eventId にユニークインデックスを張ると、テストでは正しく見えます。そして本番環境のどこかの時点で、必要なイベントを拒否するか、処理済みのイベントを受け入れるかのどちらかが起きます。どちらの失敗も、自分からは名乗り出ません。

頼りになるのは 2 つのフィールドです。occurredAt は「このイベントが発生した日時(ミリ秒単位のタイムスタンプ)」、attemptNumber は「0 から始まる、このイベントをサービスに通知する何回目の試行か」です。

診断に役立つのは attemptNumber です。0 より大きければリトライで、エンドポイントが以前に失敗したことを意味します。その場合、イベントは発生した順序どおりには届いていません。

バッチ処理がハンドラーの形を変える

HubSpot は、1 リクエストにつき 1 件の通知を送るわけではありません。「1 つのリクエストでオブジェクトの配列を受け取ることを想定してください。バッチのサイズは変わることがありますが、100 件未満の通知になります。」

その結果が 3 つあります。

ハンドラーは関数ではなく、ループになります。5 秒の応答期限は配列全体にかかります。1 件あたり 30ms のデータベース往復だけで、約 160 件で応答期限を超えます。1 件ごとにほかの処理が加われば、配列が 100 件に達する前に超えることもあります。

HTTP には、部分的な失敗を表す語彙がありません。90 件中 40 件目で例外が起きても、HubSpot に「最初の 39 件は処理できた」とは伝えられません。返せるのは、バッチ全体で 1 つのステータスだけです。2xx 以外を返せば、リトライで 90 件すべてが戻ってきます。200 を返せば、たどり着けなかった 50 件は失われます。

そのため、ほかと同じ結論になります。まず配列全体を永続化し、200 を返し、処理は後で行います。バッチの書き込みは 90 回ではなく 1 回の INSERT で済むので、時間の問題も解決します。

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
}

順序が崩れる理由と、重複排除では直せない理由

営業担当者が、1 つの取引を 90 秒間に 4 回編集します。通知は 4 件です。エンドポイントが一時的に不調で、2 件目を拒否します。HubSpot はそれを「最大 10 回」リトライし、そのリトライは「次の 24 時間にわたって、リクエストの間隔を変えながら分散されます」。

こうして 2 件目の通知は、3 件目と 4 件目の後に届きます。ハンドラーはそれを適用します。取引のステージは 2 回目の編集時点のものになり、誰かがまたそのレコードに触れるまで、そのままです。

2 回処理された通知はありませんでした。重複排除は完璧に機能していました。それでもデータは間違っています。

重要なのはこの区別です。冪等性が防ぐのは「同じイベントを 2 回処理すること」です。「新しい状態の上に古い状態を適用すること」を防ぐ仕組みは、冪等性のどこにもありません。この 2 つは別の失敗で、直し方も違います。そして CRM のストリームでは同じオブジェクトが繰り返し更新されるため、決済のストリームよりはるかに頻繁に 2 つ目の失敗が起きます。

対処は、書き込みそのものに単調増加のガードを入れることです。

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 には、自分の NOW() ではなく occurredAt を使います。自分の時計が記録するのは受け取った時刻で、受信の順序こそが信頼できないものだからです。本物の不変条件を持つステートマシンでどうするかも含めた全体像は、「同じ Webhook が 2 回届いた」で説明しています。

ちゃんと機能する重複排除キー

eventId が使えない以上、組み合わせると 1 つの変更を特定できるフィールドから複合キーを作ります。

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)
);

1 つのオブジェクト、1 種類の変更、1 ミリ秒です。同じフィールドに対する本当に別々の 2 つの編集が同じミリ秒に起きると、衝突します。人の手による CRM の編集では、起こりうるものの無視できる程度のリスクです。一括インポートでは、無視できないリスクです。プロパティ変更のサブスクリプションでは、キーに propertyName を加えてください。subscriptionType はどのプロパティが変わっても同じなので、これがないと、同じミリ秒に変わった 2 つの別々のプロパティも衝突します。

これははっきり書いておく価値があります。Stripe が与えてくれるものより一段劣るからです。プロバイダーが発行する一意のイベント ID があれば、重複排除は正確です。複合キーでは、とても良いヒューリスティックにとどまります。テーブル定義の横にこの選択を書き残し、意図的だったことが次の担当者にわかるようにしてください。

一括インポートが負荷試験になる

顧客が 50,000 件のコンタクトをインポートすると、短時間に 50,000 件の通知が生まれ、配列にまとめて送られてきます。このとき、ここまでの話がすべて同時に机上の話ではなくなります。バッチは最大になり、データベースは最も忙しくなり、5 秒の応答期限は最も厳しくなります。そして失敗すれば、そのリトライが何時間も後に、リアルタイムの編集と入り混じって届きます。

手作りの単発イベントでしかテストしていないなら、壊れるケースはテストできていません。タイムスタンプをわざと入れ替えた 100 件のバッチを自分に送ってください。そして最終的な状態が、最後に届いたものではなく、occurredAt が最も新しいものになっているかを確認します。

AnyHook の位置づけ

順序の問題は、どれも通信路の問題ではありません。HubSpot が 2 件目の通知を 4 件目の後に送れば、どんな中継サービスもそれを取り消せません。できると謳う製品は誇大広告です。

中継サービスが変えるのは、その周りのすべてです。

HubSpot の送信先を in.anyhook.net/you/hubspot にすると、AnyHook はバッチの大きさにかかわらず約 1 秒で 200 を返します。5 秒の応答期限が、処理の仕方を縛ることはなくなります。エンドポイントへの配信は HubSpot のスケジュールではなく AnyHook 自身のスケジュールでリトライされるので、短い障害で順序の入れ替わる時間幅が 24 時間分広がることもありません。

ほかにも次の点が変わります。

  • 配信を試みる前に、すべてのバッチがヘッダーとボディごと保存されます。「実際に何を、どの順で受け取ったのか」に、データベースの現在の状態から再構成するのではなく、ログから答えられます
  • ある時間帯の失敗したイベントをまとめて再送すれば、インポートで失われた分を、修正したハンドラーに対して流し直せます。顧客にもう一度インポートを頼む必要はありません
  • 配信の失敗は数えられ、1 回、5 回、20 回でアラートが送られます。何かがおかしいことを知る手がかりが、0 より大きい attemptNumber だけではなくなります

それでもハンドラーにはバージョンガードが必要です。そこは受信側の担当です。

まとめ

CRM の分野では、決済の分野とは違う反射神経が求められます。何かにインデックスを張る前に、フィールドのドキュメントを読んでください。HubSpot は、そのイベント ID には期待するほどの重みを預けられないと、珍しいほど正直に書いています。順序は occurredAt で決め、重複排除には意図して選んだ複合キーを使い、すべての書き込みを古い状態から守ってください。

ハンドラーが今、タイムスタンプの条件なしで UPDATE ... WHERE id = $1 を実行しているなら、問題は古いデータをすでに書き込んだかどうかではありません。誰かがそれに気づいたかどうかです。

すべての記事2026年8月6日 · 8 分

Webhook を、もう取りこぼさない。

URL を 1 つ変えるだけで、リトライ、イベントログ、1 クリックでの再送が使えます。先に動きを見ることもできます。新規登録して 30 秒の障害訓練を実行すると、サーバーがダウンしていてもイベントが失われない様子を確認できます。サーバーを用意する必要はありません。