同じURL短縮サービスのAPIを使って構築している二つのチームが、まったく異なる統合アーキテクチャに行き着くことがよくあります。一方のチームはWebhookエンドポイントを設定してリアルタイムですべてのクリックに反応します。もう一方のチームは5分ごとにアナリティクスAPIをポーリングするcronジョブを書きます。どちらも有効です。この二者の選択は、レイテンシ、運用上のオーバーヘッド、そして何か問題が起きたときのシステムの劣化の度合いに実際の影響を与えます。
この記事では実際のトレードオフを説明します。
二つのパターン#
ポーリング#
ポーリングとは、コードがスケジュールに従ってAPIに最近のクリックデータを問い合わせることです。cronジョブが起動し、/v1/analytics/workspaces/{id}/clicks/recent または /v1/analytics/workspaces/{id}/summary を呼び出し、結果を処理し、次のインターバルまでスリープします。
データフローはプルベースです。インフラがすべてのインタラクションを開始します。APIサーバーは内部システムについて何も知りません--送信されたクエリに答えるだけです。
Webhook#
WebhookとはElidoのサーバーがクリックが処理されてすぐに click.recorded イベントをあなたのHTTPSエンドポイントにプッシュすることです。レシーバーがそれを処理し、2xxを返すと、デリバリーが成功として記録されます。
データフローはプッシュベースです。プラットフォームが接触を開始します。エンドポイントはインターネットから到達可能で、TLSが必要で、確実に応答する必要があります。
ポーリングが正しい選択の場合#
ポーリングは特定の条件のセットに適しています。これらのほとんどがあなたの状況に当てはまる場合は、ポーリングから始め、具体的な問題が迫るときにのみWebhookに移行してください。
統合の両側をコントロールしている場合。 コンシューマーが自分が所有・運用するダッシュボードまたはレポーティングツールの場合、ポーリングは予測可能で制限された動作を提供します。インターバルを自分で決め、タイムウィンドウを自分で決め、部分的な結果の処理方法を自分で決めます。
ユースケースが遡及的な場合。 週次のキャンペーンレポート、月次の集計ジョブ、照合パイプラインはサブ分のレイテンシから恩恵を受けません。/summary または /breakdown/country に対して1時間ごとに実行するcronジョブは、リトライ処理を持つステートフルなWebhookレシーバーよりもアーキテクチャ的にシンプルで推論しやすいです。
公開エンドポイントを公開する手段がない場合。 WebhookはElidoのインフラから到達可能なURLを必要とします。統合がプライベートネットワーク内、安定したURLのないLambda関数内、または開発者のローカルマシン上で動く場合、インバウンドHTTPSエンドポイントの設定はレイテンシの恩恵よりも運用上の複雑さが大きくなることがあります。
ボリュームが低い場合。 1日に数千クリック程度では、リアルタイムと5分の遅延の差がエンドユーザーに見えることはほとんどありません。ポーリングは理解が簡単で、デバッグが簡単で、インフラ上の驚きがありません。
Webhookが正しい選択の場合#
Webhookは、レイテンシが「あれば便利」ではなくプロダクトの要件である場合に意味を持ちます。
ライブカウンターまたはリアルタイムUXを構築している場合。 プロダクトがリダイレクトの発生から数秒以内に目に見えて更新されるクリック数をユーザーに表示する場合、合理的なインターバルでのポーリングは目立って古くなります。click.recorded イベントでRedisカウンターをインクリメントし、WebSocketまたはSSE接続を通じてフロントエンドに表示するWebhookハンドラーが、アナリティクスAPIを叩きすぎることなくこれを実現するアーキテクチャです。
クリックごとにCRMレコードを補強している場合。 クリックイベントをコンタクトレコードに紐付ける--アウトバウンドメールのリンクをたどった特定の見込み客を特定し、CRMのタイムラインを更新する--ことは時間的に敏感です。ポーリングジョブが5分後に追いつく頃には、営業担当者がすでに電話をかけているかもしれません。クリックの数秒以内にCRMの更新を発火させるWebhookハンドラーが正しいツールです。
イベント駆動型ワークフローを実行している場合。 クリックイベントによってトリガーされるワークフロー--リンクがクリックされたときにフォローアップメールを送信する、サブスクライバーのセグメントを更新する、在庫数をデクリメントする--は自然なWebhookのコンシューマーです。click.recorded イベントはラウンドトリップクエリなしに即座に行動するのに十分なデータを持っています。
安定した、公開到達可能なHTTPSエンドポイントを持っている場合。 これが他のすべてが依存する前提条件です。他のプロバイダー(Stripe、GitHub、Twilio)からのインバウンドWebhookを受け入れる本番インフラがすでにある場合、同じレシーバーにElidoを追加するのは低摩擦です。
Webhookの隠れたコスト#
Webhookはシンプルに聞こえます:サーバーがPOSTを送り、あなたがそれを処理する。実際の実装サーフェスはより大きいです。
署名の検証#
ElidoはすべてのwebhookデリバリーにアHTMAC-SHA256で署名します。署名フォーマットは v1=HMAC-SHA256(secret, "${unix_timestamp}.${body}") で、X-Webhook-Signature ヘッダーで配信されます。タイムスタンプは X-Webhook-Timestamp に別途送信されます。どちらも services/webhook-dispatcher/internal/signing/hmac.go で生成されます。
ペイロードを処理する前にこの署名を検証しなければなりません。検証をスキップするレシーバーは、WebhookのURLを発見した誰かからのなりすましリクエストを含め、エンドポイントに届くあらゆるPOSTを処理してしまいます。
ペイロードで何かを行う前に署名を検証する最小限のTypeScriptのExpressハンドラーを示します:
import express, { Request, Response } from "express";
import crypto from "crypto";
const app = express();
// Use raw body middleware - JSON parsers consume the stream before you can hash it
app.use("/webhook", express.raw({ type: "application/json" }));
function verifySignature(
secret: string,
signature: string,
timestamp: string,
rawBody: Buffer,
): boolean {
const message = `${timestamp}.${rawBody.toString("utf8")}`;
const expected =
"v1=" + crypto.createHmac("sha256", secret).update(message).digest("hex");
// Use timingSafeEqual to prevent timing-based enumeration
return crypto.timingSafeEqual(
Buffer.from(signature, "utf8"),
Buffer.from(expected, "utf8"),
);
}
app.post("/webhook", (req: Request, res: Response) => {
const signature = req.headers["x-webhook-signature"] as string;
const timestamp = req.headers["x-webhook-timestamp"] as string;
if (!signature || !timestamp) {
return res.status(400).json({ error: "missing signature headers" });
}
// Reject payloads older than 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp, 10);
if (age > 300) {
return res.status(400).json({ error: "payload too old" });
}
if (
!verifySignature(
process.env.WEBHOOK_SECRET!,
signature,
timestamp,
req.body as Buffer,
)
) {
return res.status(401).json({ error: "invalid signature" });
}
const event = JSON.parse((req.body as Buffer).toString("utf8"));
if (event.type === "click.recorded") {
// Handle the click event
console.log("click recorded:", event.data);
}
// Always return 2xx promptly - do heavy processing async
return res.status(200).json({ received: true });
});
リプレイウィンドウ#
上の例のタイムスタンプチェックは、Elidoのドキュメントがリプレイウィンドウと呼ぶものを強制します。これなしでは、単一の有効な署名済みペイロードを取得した攻撃者が無制限にリプレイできます--署名は固定のタイムスタンプから計算されるため永遠に有効のままです。このチェックがあれば、署名が有効かどうかに関わらず5分以上経過したペイロードは拒否されます。
インフラが処理できる許容範囲を設定してください。5分は従来のデフォルトであり、Stripeが使用しているものと一致します。レシーバーがデプロイ中に数分間オフラインになることがある場合、このウィンドウによって再起動してまだ処理中のデリバリーを処理する時間が得られます。
リトライと冪等性#
Elidoのwebhook-dispatcherはバックオフスケジュールで失敗したデリバリーをリトライします。最初のリトライは1分後、2番目は5分後、3番目は15分後です。webhook_deliveries スキーマで定義されているように、デリバリーごとの最大試行回数はデフォルトで3回です。3回の試行が失敗すると、デリバリーは永続的に失敗としてマークされ、通知ダッシュボードに表示されます。
これはレシーバーが同じイベントを複数回受け取る可能性があることを意味します。副作用を持つ処理--データベースへの書き込み、メールの送信、カウンターの更新--はすべて冪等性を持つ必要があります。X-Webhook-Delivery ヘッダーには冪等性キーとして使用できる安定したデリバリーIDが含まれています。
// Before processing, check whether this delivery has already been handled
const deliveryId = req.headers["x-webhook-delivery"] as string;
const alreadyProcessed = await redis.get(`webhook:delivery:${deliveryId}`);
if (alreadyProcessed) {
return res.status(200).json({ received: true, duplicate: true });
}
// Mark as processed with a TTL that covers the retry window
await redis.set(`webhook:delivery:${deliveryId}`, "1", "EX", 3600);
エンドポイントは高可用性である必要がある#
リトライウィンドウは有限です。レシーバーがおよそ21分以上(1 + 5 + 15)ダウンしている場合、デリバリーは試行回数を使い果たして永続的に失敗します。確実なデリバリーが重要なイベント--CRMの補強、請求のhook--では、レシーバーインフラに適切な可用性が必要です。時折再起動するホビイストサーバーでは不十分です。
これはインバウンドHTTPに慣れていないチームにとって、Webhookのコストで最も過小評価されているものです。ポーリングは優雅に劣化します。ポーリングジョブが失敗しても、次のインターバルで再実行されて追いつきます。利用できないWebhookレシーバーは、照合戦略がなければイベントを永続的に失います。
ポーリングの隠れたコスト#
ポーリングは外見上シンプルに見えます。実際のコストは本番環境で蓄積します。
ラグが定義上の制約です。 5分ごとに実行されるcronジョブは、クリックデータが最大5分遅れることを意味します。ほとんどの遡及的なユースケースではこれは許容できます。ユーザーに面するものには許容できません。インターバルを短くすると改善されますが、ラグをなくすことはできません。非常に短いインターバル(1分未満)はポーリングよりもAPIへの過剰アクセスのように見え始めます。
無駄なリクエスト。 ほとんどのポーリングインターバルは前のリクエストと同じデータを返します。低トラフィックのリンクを毎分ポーリングしていて、クリックが約1時間に1回しか来ない場合、60リクエストのうち59回は何も新しいものを返しません。これらのリクエストはAPIレートリミットに対してカウントされます。
レートリミット。 ElidoのAPIは請求ティア別にサイズ設定されたワークスペースごとのレートリミットを強制します。大規模なワークスペース内の多くのリンクにわたって頻繁に実行されるポーリングジョブは、特に同じワークスペース内の他の自動化もAPIコールを行っている場合、これらの制限に達する可能性があります。これが起きるとAPIは X-RateLimit-Scope: workspace ヘッダー付きの 429 Too Many Requests を返します。
ページネーションと見逃しイベント。 /clicks/recent エンドポイントはカーソルベースのページネーションを使用しています。固定のタイムウィンドウでポーリングする場合--?from=<last_poll>&to=<now>--そのウィンドウ内のボリュームがページサイズを超えると、next_cursor をすべてのページにわたって追わない限りイベントを見逃します。ページネーションを処理しないポーリング実装は、負荷の下でデータをサイレントにドロップします。
ハイブリッドパターン#
ほとんどの本番ユースケースにとって、最良の答えはどちらか一方ではありません。
リアルタイムの反応のためにWebhookをプライマリパスとして使用します:CRMの更新、ライブカウンター、イベント駆動型ワークフロー。レイテンシは低く、インバウンドHTTPSインフラがすでにある場合、運用上のオーバーヘッドは管理可能です。
週次または日次の照合パスとしてポーリングを使用します:先週の完全な時系列をプルし、Webhookハンドラーが記録したものと合計を比較し、ギャップを特定します。これにより、停止中にリトライウィンドウを使い果たしたデリバリー、順序が乱れて届いたイベント、ローカル状態とElidoのソースオブトゥルースの間の差異が検出されます。
アナリティクスAPIはこの役割に適しています。/summary エンドポイントは単一のクエリで日付範囲の集計合計を返します。/timeseries エンドポイントは日次バケットを返します。毎晩一度実行されてCRMの記録されたクリック数を同じウィンドウのAPIサマリーと比較する照合ジョブは、データインテグリティの問題が顧客向けの問題になる前に浮かび上がらせることができます。
PythonのポーリングCron#
ポーリングから始めて後でWebhookに移行したいチームのために、schedule ライブラリを使って5分間隔で /clicks/recent を呼び出す最小限の実装を示します:
import schedule
import time
import requests
import os
API_BASE = "https://api.elido.app/v1/analytics"
WORKSPACE_ID = os.environ["ELIDO_WORKSPACE_ID"]
API_KEY = os.environ["ELIDO_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
# Track the cursor across poll intervals so we only fetch new clicks
_cursor = None
def poll_recent_clicks():
global _cursor
params = {"limit": 100}
if _cursor:
params["cursor"] = _cursor
while True:
resp = requests.get(
f"{API_BASE}/workspaces/{WORKSPACE_ID}/clicks/recent",
headers=HEADERS,
params=params,
timeout=10,
)
resp.raise_for_status()
body = resp.json()
items = body.get("items", [])
for click in items:
process_click(click)
next_cursor = body.get("next_cursor")
if not next_cursor:
# Persist the current cursor for the next run
if items:
_cursor = None # reset: next poll fetches from now
break
params["cursor"] = next_cursor
def process_click(click: dict):
# Replace with your actual processing logic
print(f"click: link={click['link_id']} country={click.get('country_code')}")
schedule.every(5).minutes.do(poll_recent_clicks)
if __name__ == "__main__":
poll_recent_clicks() # run once on startup to catch up
while True:
schedule.run_pending()
time.sleep(10)
本番デプロイでは、print を実際のシンク--データベースへの書き込み、CRM APIコール、メッセージキューへのパブリッシュ--に置き換え、requests.get 呼び出しの周囲に指数バックオフのエラー処理を追加してください。
ボットフィルタリングと統合への影響#
両方のパターンに影響する一つの詳細:Elidoのedge-redirectサービスはクリックイベントを処理パイプラインに送出する前にボットクリックをフィルタリングします。Googlebot、Bingbot、Slackbot、アップタイムモニター、curl、スクリプトライブラリ、空のUser-Agentsからのリクエストは click.recorded イベントを生成せず、アナリティクスAPIの結果にも表示されません。
これが重要な理由は、WebhookハンドラーまたはポーリングジョブがrawのHTTPリクエスト数ではなく人間によるリダイレクト数を扱うことを意味するからです。Elidoのクリックデータをサーバーサイドのメトリクス--アプリケーションのサーバーログ、CDNのアクセスログ--と相関させる場合、Elidoの数字が低くなることを想定してください。この差異はバグではありません。ボットフィルターがあなたに届く前にノイズを除去しているのです。
ボットフィルターが対象とするものと境界線上のトラフィックをサスピションスコアラーがマークする方法の詳細については、アナリティクスガイドに完全な内訳があります。Webhook署名スキームのセキュリティ特性--HMACフォーマット、タイムスタンプバインディング、何が防止されるか--については、セキュリティチェックリストを参照してください。
料金ページには、どのプランティアにWebhookエンドポイントが含まれているか、どのデリバリーボリュームキャップかの内訳があります。
ブログの関連記事#
Elidoを試す
URLを貼り付けて短縮リンクを取得
登録不要。リンクは30日間有効。永久に保存するには登録してください。
Free、登録不要 · 1日あたり2件