Webhookでイベントに対応する

WebhookのURLを設定し、検証チャレンジに合格し、すべての署名を検証し、5秒以内に受信確認を返し、ポーリングで取りこぼしたイベントを取得します。

Webhookは、レビューの受信、掲載情報の公開、投稿の公開など、アカウントに変更があったときに、サーバーにHTTP POSTを送信します。URLはアカウントごとに1つで、イベントごとのサブスクリプションはないため、1つの受信処理で、ドキュメントに記載された18種類のイベントすべてを受け取り、eventフィールドに基づいて処理を分岐します。

1. ダッシュボードでURLとシークレットを設定する

まず、手順2と3の受信処理を実装します。シークレットを生成すると署名が必須になり、エンドポイントが検証に合格するまで配信が一時停止されるため、エンドポイントの準備ができていないとすべてのイベントの配信が止まります。

  1. HTTPSエンドポイントをデプロイします。プライベートアドレスは拒否され、リダイレクトには従わないため、wwwや末尾のスラッシュに関するリダイレクトを経由しない最終URLを保存します。2. ダッシュボードのWebhookページでURLを保存します。3. 署名シークレットを生成します。表示されるのは1度だけです。サーバー上にLISTINGSAPI_WEBHOOK_SECRETとして保存します。4. 「エンドポイントを検証」をクリックします。プラットフォームは手順2のチャレンジを送信し、最大10秒間待機します。

URLを変更したりシークレットを再生成したりすると検証状態がリセットされるため、まず新しいシークレットをデプロイしてから、再度検証します。詳細はWebhookの概要に記載されています。

2. 検証チャレンジに応答する

チャレンジは、予約済みのイベント名とnonceを含む、署名付きのPOSTです。nonceのみのHMAC-SHA256を小文字の16進数で表し、200で返します。

JSON
{  "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を解析して再シリアライズしないでください。そうするとバイト列が変わってしまいます。以下のどちらの受信処理も、生のリクエストボディを保持し、署名を検証し、チャレンジに応答し、イベントを永続的に保存してから応答を返します。

Flask receiver
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}, 200
Express receiver
import 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を除くすべてのイベントで、ペイロードの構造は共通です。

JSON
{  "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 をキーに書き込み、再受信しても同じ結果になるようにします。

Exact-duplicate check
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イベントに制限されるため、到着時に再取得しても最初の編集しか確認できません。時間枠が終了した後に再度取得してください。

event に基づいて分岐し、各イベントを対応するガイドに沿って作成したコードに渡します。Webhook は「いつ」を伝え、そのガイドのエンドポイントは「何を」を伝えます。

イベント発生した内容参照するガイド
profile.created拠点が作成されました。data.source は user、api、bulk_edit のいずれかです。拠点を追加する
profile.updated拠点が編集されました。data.changed_fields は手がかりであり、完全な差分ではありません。拠点を更新する
profile.deleted拠点がアーカイブされました。拠点を検索してアーカイブする
listing.submission掲載先への送信結果が確定しました。status は success、incomplete、canceled のいずれかです。掲載情報のステータスと公開リンクを確認する
connection.location_connected, connection.location_disconnectedGoogle または Facebook の掲載情報がリンクされたか、リンク解除されました。Google を接続して拠点をインポートする、Facebook ページを接続する
connection.reauth_required, connection.listing_inaccessible認証情報の有効期限が切れたか、掲載情報へのアクセスが失われました。Google を接続して拠点をインポートする
connection.google_verification_verified, connection.google_verification_failedGoogle の確認が完了しました。Google Business Profileを作成する
interaction.reviewレビューが届いたか、変更されました。フィールドは data.interaction の下にあります。レビューを取得する
interaction.responseオーナーの返信が投稿、編集、またはアーカイブされました。parent_id はレビューです。レビューに返信する
local_post.created, local_post.published, local_post.rejected, local_post.deleted投稿項目の状態が変わりました。拠点ごとに1つある data.post_item_id をキーにします。投稿を公開する
review_analytics.daily_snapshot, review_analytics.weekly_snapshot1つの拠点と期間を対象にレビューの集計が実行されました。レビュー分析

すべてのイベントのフィールド表とサンプルペイロードは、イベントリファレンスにあります。

7. ポーリングでイベントの取りこぼしから復旧する

再試行がないため、イベント群ごとに、正しい状態を確認できる読み取り手段を用意しておく必要があります。異常が見られるときだけでなく、スケジュールに従って照合し、イベントが届かなかったことを理由に 0 や「変更なし」を書き込まないでください。

イベント群再取得に使用するもの間隔
listing.submissionsyncStatus と listingUrl には GET /locations/{locationId}/listings/premium を使用します。拠点ごとに毎日実行します。
profile.*GET /locations または GET /locations/search を使用し、updatedAt で変更内容を確認します。1時間ごとに加え、各 profile.updated の60秒後に実行します。
connection.*GET /connected-accounts と、拠点を取得するたびに得られる googleVerificationStatus を使用します。1時間ごとに実行し、確認中は数分ごとに実行します。
interaction.*アカウント全体では GET /rollup_interactions、id を指定する場合は GET /reviewDetails を使用します。新しいレビューの確認は15分ごと、全体の確認は毎日実行します。
local_post.*GET /posts/{postId} または GET /locations/{locationId}/posts を使用します。投稿が処理中の間は1時間ごとに実行します。
review_analytics.*対象期間の日付を指定して GET /locations/{locationId}/review-analytics-overview を使用します。毎日、集計後に実行します。

ダッシュボードは、すべての試行をステータスとともに記録します。success は配信済みを表し、HTTP コード、timeout、network_error、error はエンドポイントに到達したものの失敗したことを意味し、blocked_url、endpoint_unverified、rate_limited はリクエストがまったく行われなかったことを意味するため、修正対象はコードではなく設定またはプランです。完全な一覧は、配信の動作にあります。

次の手順