Webhookでイベントに対応する
WebhookのURLを設定し、検証チャレンジに合格し、すべての署名を検証し、5秒以内に受信確認を返し、ポーリングで取りこぼしたイベントを取得します。
Webhookは、レビューの受信、掲載情報の公開、投稿の公開など、アカウントに変更があったときに、サーバーにHTTP POSTを送信します。URLはアカウントごとに1つで、イベントごとのサブスクリプションはないため、1つの受信処理で、ドキュメントに記載された18種類のイベントすべてを受け取り、eventフィールドに基づいて処理を分岐します。
1. ダッシュボードでURLとシークレットを設定する
まず、手順2と3の受信処理を実装します。シークレットを生成すると署名が必須になり、エンドポイントが検証に合格するまで配信が一時停止されるため、エンドポイントの準備ができていないとすべてのイベントの配信が止まります。
- HTTPSエンドポイントをデプロイします。プライベートアドレスは拒否され、リダイレクトには従わないため、
wwwや末尾のスラッシュに関するリダイレクトを経由しない最終URLを保存します。2. ダッシュボードのWebhookページでURLを保存します。3. 署名シークレットを生成します。表示されるのは1度だけです。サーバー上にLISTINGSAPI_WEBHOOK_SECRETとして保存します。4. 「エンドポイントを検証」をクリックします。プラットフォームは手順2のチャレンジを送信し、最大10秒間待機します。
URLを変更したりシークレットを再生成したりすると検証状態がリセットされるため、まず新しいシークレットをデプロイしてから、再度検証します。詳細はWebhookの概要に記載されています。
2. 検証チャレンジに応答する
チャレンジは、予約済みのイベント名とnonceを含む、署名付きのPOSTです。nonceのみのHMAC-SHA256を小文字の16進数で表し、200で返します。
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}3. 配信ごとに署名を検証する
署名付きの各リクエストにはX-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>が含まれます。受信したバイト列をそのまま使って同じダイジェストを計算し、定数時間で比較して、一致しない場合は401を返します。先にJSONを解析して再シリアライズしないでください。そうするとバイト列が変わってしまいます。以下のどちらの受信処理も、生のリクエストボディを保持し、署名を検証し、チャレンジに応答し、イベントを永続的に保存してから応答を返します。
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() def signature_is_valid(raw_body: bytes, header: str | None) -> bool: digest = hmac.new(SECRET, raw_body, hashlib.sha256).digest() expected = "sha256=" + base64.b64encode(digest).decode() return hmac.compare_digest(header or "", expected) @app.post("/webhooks/listingsapi")def webhooks(): raw = request.get_data() # raw bytes, exactly as received if not signature_is_valid(raw, request.headers.get("X-ListingsAPI-Signature")): abort(401) body = request.get_json(silent=True) or {} # One-time handshake: hex digest of the nonce, not the body. if body.get("event") == "endpoint.verification": return hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest(), 200 # Real event: durable write first, then acknowledge. Swap for your queue. inbox.put(raw) # must be committed before the 200 goes out return {"received": True}, 200import crypto from 'crypto';import express from 'express'; const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET as string; // express.raw keeps req.body as the untouched Buffer the HMAC covers.app.post('/webhooks/listingsapi', express.raw({ type: '*/*' }), async (req, res) => {const raw: Buffer = req.body;const digest = crypto.createHmac('sha256', SECRET).update(raw).digest('base64');const expected = Buffer.from('sha256=' + digest);const given = Buffer.from(req.get('X-ListingsAPI-Signature') ?? '');if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) { return res.status(401).send('bad signature');} const body = JSON.parse(raw.toString('utf8')); // One-time handshake: hex digest of the nonce, not the body.if (body.event === 'endpoint.verification') { const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex'); return res.status(200).send(answer);} // Real event: durable write first, then acknowledge. Swap for your queue.await inbox.put(raw); // must be committed before the 200 goes outreturn res.status(200).json({ received: true });});アカウントでシークレットを一度も生成していない場合、配信は署名なしで届きます。このモードを前提に実装しないでください。上記の受信処理を稼働させたら、シークレットを生成します。
4. 速やかに受信確認を返し、非同期で処理する
1回の配信は、接続タイムアウトが5秒、読み取りタイムアウトが5秒の1つのPOSTであり、試行は厳密に1回だけです。再試行、バックオフ、再配信はありません。ハンドラーの処理に6秒かかった場合や、サーバーが2分間停止した場合、それらのイベントは失われます。配信済みと見なされるのは2xxだけで、3xx、4xx、5xxは失敗です。
そのため、リクエスト処理では、検証する、生のリクエストボディをキューまたはテーブルに書き込んでコミットを待つ、200を返す、という3つのことだけを行います。データの補完、REST APIへのコールバック、サードパーティとやり取りするすべての処理は、キューから実行します。listing.submissionを除くすべてのイベントで、ペイロードの構造は共通です。
{ "event": "connection.location_connected", "timestamp": "2026-08-25T14:32:10Z", "account_id": 11073, "location_id": "279381", "data": { "platform": "google", "connected_account_id": "ba03bc4b-9f8c-4d3b-8e93-9628034c63cc" }}location_idは数値文字列ですが、interaction.*イベントでは例外的に正規化されずにそのまま渡され、nullの場合もあります。listing.submissionにはtimestampもaccount_idもなく、statusとlive_linkはトップレベルに配置されます。data内のオプションフィールドは、nullとして送信されるのではなく、省略されます。認識できないeventに対しても200を返してください。ドキュメントに記載されていないイベント群も同じURLに届きます。
5. 重複を排除し、順序が前後する配信に対応する
同じイベントを2回受信することがあり、同じリソースの id は何度も受信します。interaction.review はレビューが変更されるたびに同じインタラクションの id で再発火し、どのペイロードにもバージョンは含まれません。したがって、id とイベント名の組み合わせだけで重複と判断して破棄しないでください。破棄すると、実際の編集を取りこぼします。生のリクエストボディをハッシュ化し、短い時間枠内の再受信を無視して完全に同一の重複を検出したうえで、リソースの id をキーに書き込み、再受信しても同じ結果になるようにします。
import hashlibimport time SEEN: dict[str, float] = {} # use Redis with a TTL in productionWINDOW_SECONDS = 600 def is_duplicate(raw_body: bytes) -> bool: key = hashlib.sha256(raw_body).hexdigest() now = time.time() for k, seen_at in list(SEEN.items()): if now - seen_at > WINDOW_SECONDS: del SEEN[k] if key in SEEN: return True SEEN[key] = now return False順序も保証されません。イベントは独立したパイプラインから送られるため、local_post.published が local_post.created より先に届くことがあり、timestamp に記録されるのは変更が発生した時刻ではなく、配信が構築された時刻です。順序が重要な場合は、REST API からリソースを再取得し、その取得結果を正としてください。profile.updated にはデバウンス処理も適用され、拠点ごとに60秒につき1イベントに制限されるため、到着時に再取得しても最初の編集しか確認できません。時間枠が終了した後に再度取得してください。
6. イベントを他のガイドに対応付ける
event に基づいて分岐し、各イベントを対応するガイドに沿って作成したコードに渡します。Webhook は「いつ」を伝え、そのガイドのエンドポイントは「何を」を伝えます。
すべてのイベントのフィールド表とサンプルペイロードは、イベントリファレンスにあります。
7. ポーリングでイベントの取りこぼしから復旧する
再試行がないため、イベント群ごとに、正しい状態を確認できる読み取り手段を用意しておく必要があります。異常が見られるときだけでなく、スケジュールに従って照合し、イベントが届かなかったことを理由に 0 や「変更なし」を書き込まないでください。
ダッシュボードは、すべての試行をステータスとともに記録します。success は配信済みを表し、HTTP コード、timeout、network_error、error はエンドポイントに到達したものの失敗したことを意味し、blocked_url、endpoint_unverified、rate_limited はリクエストがまったく行われなかったことを意味するため、修正対象はコードではなく設定またはプランです。完全な一覧は、配信の動作にあります。
次の手順
- レビューの取得: 多くの連携で最初に実装する機能群で、ロールアップの定期取得が取りこぼし対策になります。
- 掲載情報のステータスと公開リンクの確認:
listing.submissionが通知する内容です。 - リファレンス: Webhook の概要、署名と検証、配信の動作、イベントリファレンス。