使用 webhook 回應事件

設定您的 webhook URL、通過驗證挑戰、驗證每個簽章、在 5 秒內確認接收,並透過輪詢補回遺漏的事件。

當您的帳戶發生變更時,webhook 會將 HTTP POST 推送至您的伺服器,例如收到評論、商家資訊上線或貼文發布。每個帳戶只有一個 URL,且無法依個別事件訂閱,因此同一個接收端會收到文件記載的全部 18 種事件,並依據 event 欄位分流處理。

1. 在儀表板中設定 URL 和密鑰

請先依照步驟 2 和 3 建置接收端。產生密鑰後,簽章將成為必要條件,而且在您的端點通過驗證之前,傳送作業會暫停,因此尚未準備就緒的端點會阻擋所有事件。

  1. 部署 HTTPS 端點。系統會拒絕私人位址,且不會跟隨重新導向,因此請儲存最終 URL,確保抵達該 URL 前不需經過 www 或尾端斜線重新導向。2. 在儀表板的 Webhook 頁面儲存 URL。3. 產生簽章密鑰。密鑰只會顯示一次。請在您的伺服器上將其儲存為 LISTINGSAPI_WEBHOOK_SECRET。4. 按一下「驗證端點」。平台會傳送步驟 2 中的挑戰,並等待最多 10 秒。

變更 URL 或重新產生密鑰都會重設驗證狀態,因此請先部署新密鑰,再重新驗證。詳細資訊請參閱 Webhook 概覽。

2. 回應驗證挑戰

挑戰是一個帶有簽章的 POST,其中包含保留的事件名稱和一個 nonce。請回傳 200,並附上僅針對 nonce 計算的 HMAC-SHA256,以小寫十六進位表示。

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. 快速確認接收並以非同步方式處理

每次傳送都是一個 POST,連線逾時和讀取逾時皆為 5 秒,而且只會嘗試一次。系統不會重試、不會退避,也不會重播。如果您的處理常式耗時 6 秒,或您的伺服器停機 2 分鐘,這些事件就會遺失。只有 2xx 才算傳送成功;3xx、4xx 和 5xx 都算失敗。

因此,請求處理流程只做三件事:驗證、將原始主體寫入佇列或資料表並等待提交完成,以及回傳 200。資料補充、回頭呼叫 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. 去除重複事件並容許事件未依序送達

您可能會收到同一個事件兩次,也會多次收到相同的資源 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 也經過防抖動(debounce)處理,每個地點每 60 秒只產生一個事件,因此在事件送達時重新讀取只會看到第一次編輯;請在此時間範圍結束後再次讀取。

請依據 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_disconnected已連結或取消連結 Google 或 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貼文項目的狀態已變更。請以 data.post_item_id 作為鍵,每個地點各有一個。發布貼文
review_analytics.daily_snapshot, review_analytics.weekly_snapshot已針對一個地點與時段執行評論彙總。評論分析

每個事件的欄位表格與承載資料範例皆列於事件參考資料。

7. 透過輪詢補救遺漏的事件

由於沒有重試機制,每個事件類別都需要備妥可作為最終依據的讀取方式。請定期核對資料,不要只在看起來有問題時才核對,也絕不可因為未收到事件,就寫入 0 或「無變更」。

類別重新讀取方式間隔
listing.submission使用 GET /locations/{locationId}/listings/premium 讀取 syncStatus 與 listingUrl。每個地點每日一次。
profile.*GET /locations 或 GET /locations/search;updatedAt 顯示變更的內容。每小時一次,並在每次 profile.updated 的 60 秒後再讀取一次。
connection.*GET /connected-accounts,以及任何地點讀取結果中的 googleVerificationStatus。每小時一次;驗證期間每隔幾分鐘一次。
interaction.*使用 GET /rollup_interactions 讀取整個帳戶的資料,使用 GET /reviewDetails 依 id 讀取。每 15 分鐘檢查新評論,每日全面檢查一次。
local_post.*GET /posts/{postId} 或 GET /locations/{locationId}/posts。貼文處理期間每小時一次。
review_analytics.*使用 GET /locations/{locationId}/review-analytics-overview,並指定該時段的日期。每日彙總後一次。

儀表板會記錄每次嘗試及其狀態。success 表示已送達;HTTP 狀態碼、timeout、network_error 或 error 表示請求已到達您的端點,但處理失敗;blocked_url、endpoint_unverified 與 rate_limited 表示根本未發出請求,因此需要修正的是設定或方案,而非程式碼。完整清單請參閱傳送行為。

後續步驟