2分で読了機能

リンクイベント向けWebhook: ペイロード、署名、リトライ

リンクイベント向けURL短縮Webhookの実際のペイロードエンベロープ、NodeとPythonでのX-Webhook-Signature HMAC検証、リトライポリシーと重複排除キーを解説します。

Marius Voß
DevRel · edge infra
URL短縮Webhookのピクセル風図解: link.created、link.updated、member.invitedの各イベントが署名付き配信を経てevent、siem、discordの各エンドポイントに届く。下部にはHMAC-SHA256 v1=、10秒タイムアウト、3回の試行という表記。

ElidoのURL短縮Webhookは、ワークスペース内で何かが変わるたびに、署名付きのJSONエンベロープをあなたのHTTPSエンドポイントにPOSTします。リンクが作成された、編集された、削除された、期限切れになった、クリック上限に達した、メンバーが招待された、ドメインが検証された、といった具合です。各リクエストにはX-Webhook-Signature: v1=<hex>ヘッダーが付き、これは{timestamp}.{raw_body}に対するHMAC-SHA256です。失敗した配信は、約20分の間に3回まで試行されます。

現時点で送られないのは、リンククリックのwebhookです。クリックは、代わりに分析APIとイベント転送機能を経由して届きます。その詳細は記事の最後で説明します。この記事はAPIサーフェスのうちアウトバウンド側を扱うもので、インバウンド側はURL短縮API + SDKクイックスタートが、リンクイベントの元となる仕組みはfeaturesクラスターのコーナーストーン記事であるスマートリンク解説がカバーしています。

現時点でwebhookを発火させるリンクイベント一覧

以下のイベントはすべて、名前で購読したwebhookエンドポイントに届きます。ダッシュボードの新規エンドポイントフォームには、最も一般的な8つについてチェックボックスが用意されていますが、APIは一覧にあるどの名前でも受け付けます。

イベント発火するタイミングダッシュボードのチェックボックス
link.createdリンクが作成されたとき(1件ずつでも一括インポートでも)あり
link.updated遷移先、設定、ステータスの変更、一括編集、復元があったときあり
link.deletedリンクが削除されたとき(単体でも一括でも)あり
link.expiredリンクが有効期限を過ぎたときAPIのみ
link.cap_reachedリンクが最大クリック数に達したときAPIのみ
link.brokenリンク切れチェックで遷移先の失敗が検出されたときAPIのみ
workspace.created、workspace.updatedワークスペースがプロビジョニングされた、または設定が変更されたときあり
member.invited、member.removedメンバーが追加された(直接、SCIM経由、招待の承諾)、または削除されたときあり
member.role_changedメンバーのロールが変更されたときAPIのみ
invitation.created、invitation.accepted招待が送信された、または承諾されたときAPIのみ
domain.verified、domain.ssl_failedカスタムドメインがDNSチェックに合格した、または24時間経ってもまだ失敗しているときAPIのみ
audit.event監査ログのエントリが発生したときあり

暗号化リンクを使う場合は、さらにlink.encrypted_createdとlink.encryption_rotatedの2つが加わります。この一覧を手動で最新に保ちたくない場合は、siemエンドポイントを作成してください。これは購読フィルターなしで、監査エントリを含むワークスペース内のすべてのイベントを受け取ります。

ロードマップに載っているが、まだ稼働していないもの: click.created(サンプリングされたクリックストリーム)、billing.subscription_upgradedのような課金イベント、「フォルダXのリンクだけ」のようなエンドポイントごとのフィルターです。これらの名前は今日時点では購読しないでください。何も発行されていません。

APIでwebhookエンドポイントを作成する

エンドポイントは1つのワークスペースに属します。/v1/workspaces/{workspace_id}/webhooksへのPOSTで作成します。

curl -X POST "https://api.elido.app/v1/workspaces/$WORKSPACE_ID/webhooks" \
  -H "Authorization: Bearer elido_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/elido",
    "events": ["link.created", "link.updated", "link.deleted"],
    "description": "CRM sync",
    "kind": "event"
  }'

レスポンスは201で、エンドポイントの情報に加え、eventとsiemの種類については一度限りのシークレットが返されます。

{
  "endpoint": {
    "id": 7,
    "workspace_id": 42,
    "url": "https://hooks.example.com/elido",
    "events": ["link.created", "link.updated", "link.deleted"],
    "is_active": true,
    "description": "CRM sync",
    "kind": "event",
    "config": {},
    "created_at": "2026-09-21T09:12:44Z"
  },
  "secret": "whsec_9f2c..."
}

シークレットはElidoが生成するもので、こちらから送るものではありません。以降の呼び出しでは返されないため、今すぐコピーしてください。kindには5つの値があります。eventとsiemは、この記事で扱う署名付きJSON配信です。discord、telegram、sentryは同じイベントをチャットメッセージやSentryイベントの形に変換し、URLまたは暗号化されたボットトークンで認証を行い、HMACヘッダーは持ちません。

今月、権限まわりが変更されました。エンドポイントの読み取りと配信ログの閲覧にはworkspace.viewが必要です。作成、編集、削除、シークレットのローテーション、配信の再送信にはworkspace.editが必要で、これは管理者またはオーナーを意味します。APIキーは発行されたワークスペース内でのみ機能し、作成時に選ばれたロールを超えることはないため、viewerレベルのキーで上のPOSTを実行すると403が返ります。完全なリファレンスはwebhookドキュメントにあります。

Webhookペイロードのエンベロープ

署名付きの配信はすべて、同じ4フィールドのエンベロープを持ちます。dataには変更されたレコードが入るため、リンクイベントの場合はリンクの行そのものになります。

{
  "type": "link.created",
  "workspace_id": 42,
  "data": {
    "id": 91834,
    "workspace_id": 42,
    "domain_id": 3,
    "slug": "spring-sale",
    "destination_url": "https://shop.example.com/spring",
    "title": "Spring sale landing",
    "tags": ["newsletter"],
    "status": "active",
    "expires_at": null,
    "max_clicks": null,
    "redirect_status": 302,
    "created_by_user_id": 17,
    "created_at": "2026-09-21T09:14:02.184311Z"
  },
  "timestamp": "2026-09-21T09:14:02Z"
}

このサンプルは省略版です。実際のdataには、ターゲティングルール、フォルダ、キャンペーン、スキャン関連のフィールドまで含め、リンクのすべてのカラムが含まれます。ボディにはイベントIDもshort_urlも含まれないため、必要であれば自分のドメインとslugから短縮URLを組み立ててください。スケジュール系のイベントは、より小さいオブジェクトを送ります。link.expiredはlink_id、slug、destination_urlを持ち、link.cap_reachedはさらにcapとclicksを持ちます。

今週より前にペイロードをログに記録していた方への注意点があります。機密フィールドは、Elidoから出る前に取り除かれるようになりました。以前は、パスワード保護されたリンクのpassword_hashや招待のtokenがdataに含まれていましたが、新しい配信でも配信ログでも、もう含まれません。過去のペイロードを保存している場合は、そのデータを削除する価値があります。

X-Webhook-Signatureヘッダーを検証する

署名付きの各リクエストには、次のヘッダーが付きます。

X-Webhook-Signature: v1=5d8f0c3e...
X-Elido-Signature: v1=5d8f0c3e...
X-Webhook-Timestamp: 1790068442
X-Webhook-Event: link.created
X-Webhook-Delivery: 55120
User-Agent: Elido-Webhooks/1.0

2つの署名ヘッダーは同じ値を持ちます。ElidoはRFC 2104で定義されている通り、whsec_...という文字列全体をキーとして、タイムスタンプ、ドット、生のボディバイト列に対してHMAC-SHA256を計算します。16進ダイジェストにはv1=という接頭辞が付きます。ヘッダーの中にt=のようなフィールドはなく、タイムスタンプは専用のヘッダーに入っています。

署名検証のフロー図: X-Webhook-SignatureとX-Webhook-Timestampの各ヘッダー、生のボディ、シークレットがts.bodyに対するHMAC-SHA256に入力され、v1=hex形式で定数時間比較され、その後受信側での5分間の鮮度チェックによって、イベントを処理するか400で拒否するかに分岐する。

Nodeでは、再シリアライズしたオブジェクトではなく、生のバイト列に署名してください。そのため、Expressではこのルートにexpress.raw({ type: "application/json" })が必要です。

import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyElido(secret, headers, rawBody, toleranceSec = 300) {
  const ts = headers["x-webhook-timestamp"] ?? "";
  if (!/^\d+$/.test(ts)) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;

  const mac = createHmac("sha256", secret).update(`${ts}.`).update(rawBody);
  const expected = Buffer.from("v1=" + mac.digest("hex"));

  // During a rotation the old secret signs X-Elido-Signature-Previous.
  return ["x-elido-signature", "x-elido-signature-previous"].some((name) => {
    const got = Buffer.from(headers[name] ?? "");
    return got.length === expected.length && timingSafeEqual(got, expected);
  });
}

この長さのチェックは重要です。Node のtimingSafeEqualは、サイズが異なるバッファに対してfalseを返すのではなく例外を投げます。標準のhmacモジュールを使ったPython版は次の通りです。

import hashlib
import hmac
import time


def verify_elido(secret: str, headers, raw_body: bytes, tolerance: int = 300) -> bool:
    ts = headers.get("X-Webhook-Timestamp", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > tolerance:
        return False
    digest = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
    expected = "v1=" + digest.hexdigest()
    for name in ("X-Elido-Signature", "X-Elido-Signature-Previous"):
        got = headers.get(name)
        if got and hmac.compare_digest(got, expected):
            return True
    return False

チェックが何度も失敗する場合は、webhook署名検証ガイドにGo版、n8nのCodeノード、そして不一致の一般的な原因をまとめています。5分の許容幅はあなた側のチェックであり、Elido側のものではありません。Elidoはリトライを含むすべての試行で新しいタイムスタンプを付与するため、正規のリトライが古く見えることはありません。この許容幅がなければ、誰かが1回のリクエストを傍受した場合、翌週にそれをリプレイしても署名は一致してしまいます。

ローテーションはPOST /v1/workspaces/{workspace_id}/webhooks/{id}/rotate-secret、またはエンドポイントページのRotateボタンから行います。新しいシークレットは一度だけ表示されます。その後7日間、各配信には旧シークレットで署名されたX-Elido-Signature-Previousも付与されます。上記の両方の関数がこれを試しているのはそのためです。その週のうちであればいつでも新しいシークレットをデプロイでき、失敗することはありません。

Webhookのリトライポリシー

配信ワーカーは数秒ごとに保留中の配信を処理するため、リンクの変更は通常数秒以内にあなたのもとに届きます。2xxのレスポンスであれば配信は完了とみなされます。2xx以外のステータス、ネットワークエラー、10秒以内にレスポンスがない場合は、失敗した試行としてカウントされます。

webhookリトライスケジュールの棒グラフ: 1回目の試行はT+0、2回目は失敗から5分後のT+5m、3回目はさらに15分後のT+20m。その後、その配信は失敗として記録され、誰かがRetryを押すかリトライエンドポイントを呼ぶまで自動での再試行は行われない。

1回の配信につき3回の試行、合計で20分です。これは意図的に短く設定されており、正直なところ、不安定なVPNの向こうにいる受信側のために私が選ぶ時間よりも短いです。あなた側で2時間の障害が起きても、自動リトライではカバーされません。それをカバーするのが配信ログです。GET /v1/workspaces/{workspace_id}/webhooks/{id}/deliveriesは、各配信についてステータス、HTTPコード、レイテンシ、試行回数、次のリトライ時刻を一覧表示し、エンドポイントページには同じ内容がRetryボタン付きで表示されます。Retry、またはPOST .../deliveries/{delivery_id}/retryは、失敗または配信済みの配信に新たに3回分の試行予算を与えて再起動し、202を返します。まだ保留中の配信に対しては409が返ります。

一部の失敗はリトライをスキップします。chat_idが欠けているTelegramエンドポイントや、不正な形式のSentry DSNは、即座に失敗として記録されます。リクエストを繰り返しても設定の問題は解決しないためです。エンドポイントを削除せずにオフラインにするには、"is_active": falseを付けてPUTを送信してください。一時停止中のエンドポイントには新しい配信は送られません。

ハンドラーが重い処理を行う場合は、まず200を返し、処理をキューに入れてください。10秒という締め切りは、遅いが成功しているハンドラーを重複配信に変えてしまうポイントであり、これが次の重複排除の話につながります。

チーム向けの受信側を設計中ですか。webhook機能ページでは、この一連の流れをダッシュボード側の視点で紹介しています。

Webhookの冪等性と順序

Elidoはat-least-once(少なくとも1回)で配信します。リトライのセクションで、重複が発生する1つのパターンを示しました。あなたのハンドラーが処理をコミットした直後に10秒の締め切りを逃し、Elidoが再送する、というものです。手動のRetryも意図的に再送を行います。

どちらのケースも同じX-Webhook-Deliveryの値を保持します。これは1回の試行ではなく、1つのエンドポイントに対する1回の配信のIDだからです。これをキーにしてください。

CREATE TABLE elido_webhook_seen (
  delivery_id BIGINT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- in the handler, inside the same transaction as your work:
INSERT INTO elido_webhook_seen (delivery_id) VALUES ($1)
ON CONFLICT (delivery_id) DO NOTHING
RETURNING delivery_id;
-- no row back means you've already handled this delivery

同じイベントを購読している2つのエンドポイントは、それぞれ別の配信IDを持つため、重複排除はエンドポイントごとに行ってください。まれに、私たちの側での再起動によって1つのイベントが別々の配信として2回キューに入ることがあります。二重書き込みが問題になる場合は、typeとdata.idとdata.updated_atを組み合わせた2つ目のガードを追加してください。

配信順序についての保証はありません。配信は期限が早いものから順に送られますが、早いイベントのリトライが、より新しいイベントより後に届くことがあります。リンクを上書きする前には、保存済みの値とdata.updated_atを比較してください。エンベロープのtimestampは1秒単位の精度しかないため、順序の判断には使わないでください。

クリックwebhookの代わりに、クリックデータがどこにあるか

ここが、この記事の古いバージョンで間違っていた部分です。現時点でclickwebhookは存在せず、click.createdは計画段階であり、まだ出荷されていません。送りっぱなしのクリック取り込みの記事で説明している通り、リダイレクトのパスは同期的な処理を持たないように保たれており、クリックはwebhookキューではなく分析ストレージに送られます。

現時点でクリック単位のデータを得るには、2つの方法があります。

  1. イベント転送機能。 短縮リンクのクリック1件ごとに、すでに使っているツール上でサーバーサイドイベントになります。Mixpanelリンククリックイベント、プロフィール向けKlaviyoクリックイベント、運用ダッシュボード向けのDatadogリダイレクトメトリクスなどです。
  2. 分析API。 GET /v1/analytics/workspaces/{workspace_id}/clicks/recentが直近のクリックを返し、clicks.csvがそれらをエクスポートします。スケジュール実行するジョブで必要な行を取得できます。

どちらが合うかはレイテンシとデータの行き先次第です。webhook対ポーリング: クリック計測にどちらを使うかでそのトレードオフを扱っています。サンプリングされたclick.createdストリームが出荷された際には、このページが真っ先にお知らせします。

コーナーストーン記事はこちら: スマートリンク解説。

関連記事

よくある質問

Elidoはリンクがクリックされるたびにwebhookを送りますか?

現時点では送りません。Webhookがカバーするのは、link.created、link.updated、link.expired、member.invitedといったワークスペースの変更です。click.createdイベントはサンプリングされたストリームとしてロードマップに載っています。今すぐクリック単位のデータが必要な場合は、分析APIか、Mixpanel、Klaviyo、Datadogといったイベント転送機能を使ってください。

Elidoのwebhook署名はどうやって検証すればいいですか?

X-Webhook-Timestampの値、ドット、生のリクエストボディに対して、whsec_で始まるシークレットをキーにHMAC-SHA256を計算します。それを16進数エンコードしてv1=を先頭に付け、X-Webhook-Signatureヘッダーと定数時間で比較してください。5分より古いタイムスタンプは拒否します。

失敗したwebhookをElidoは何回リトライしますか?

各配信は3回まで試行されます。1回目は即座に、2回目は失敗から5分後、3回目はさらに15分後です。2xx以外のステータス、ネットワークエラー、10秒より遅いレスポンスはすべて失敗としてカウントされます。3回目の後、その配信はRetryを押すまで失敗のまま扱われます。

X-Webhook-SignatureとX-Elido-Signatureの違いは何ですか?

名前が違うだけで、中身は同じです。どちらのヘッダーも同じv1=署名を持ち、X-Webhook-Signatureは古い受信側との互換性のために残されています。シークレットのローテーション中は、Elidoは旧シークレットで署名したX-Elido-Signature-Previousも7日間送信します。

同じwebhookを2回処理してしまわないようにするにはどうすればいいですか?

X-Webhook-Deliveryヘッダーの値を保存し、すでに処理済みの値を持つリクエストはスキップしてください。この値は1つのエンドポイントに対する1回の配信を識別するもので、自動リトライや手動の再送信をまたいでも変わらないため、この値にユニークインデックスを張るだけで十分です。

ワークスペース内でwebhookを作成・削除できるのは誰ですか?

ワークスペースの管理者とオーナーです。エンドポイント一覧の閲覧と配信ログの読み取りにはview権限が必要で、作成、編集、削除、シークレットのローテーション、配信の再送信にはworkspace.edit権限が必要です。APIキーは、そのキーが作成されたときに選ばれたロールを上限とします。

Elidoを試す

URLを貼り付けて短縮リンクを取得

登録不要。リンクは30日間有効。永久に保存するには登録してください。

Free、登録不要 · 1日あたり2件

Elidoを試す

EUホスティングのURL短縮サービス。カスタムドメイン、詳細な分析、オープンAPI付き。無料プラン - クレジットカード不要。

タグ
url shortener webhooks
link click webhook
webhook signature verification
webhook retry policy
webhook idempotency
webhook payload

続きを読む