通过 Webhook 响应事件
配置您的 Webhook URL,通过验证质询,验证每个签名,在 5 秒内确认接收,并通过轮询补回遗漏的事件。
当您的账户发生变化时,例如收到评价、商家信息上线或帖子发布,Webhook 会向您的服务器推送一个 HTTP POST。每个账户只有一个 URL,且不支持按事件订阅,因此同一个接收器会接收文档中列出的全部 18 种事件,并根据 event 字段进行分支处理。
1. 在控制台中设置 URL 和密钥
请先按照第 2 步和第 3 步构建接收器。生成密钥后,签名将成为必需项,并且在您的端点通过验证之前,投递会暂停,因此尚未准备就绪的端点会阻塞所有事件。
- 部署 HTTPS 端点。系统会拒绝私有地址,且不会跟随重定向,因此请保存最终 URL,确保访问该 URL 时不会先经过
www或尾部斜杠重定向。2. 在控制台的 Webhook 页面保存 URL。3. 生成签名密钥。密钥只会显示 1 次。请在您的服务器上将其存储为LISTINGSAPI_WEBHOOK_SECRET。4. 点击“验证端点”。平台会发送第 2 步中的质询,最多等待 10 秒。
更改 URL 或重新生成密钥会重置验证状态,因此请先部署新密钥,然后重新验证。详情请参阅 Webhook 概述。
2. 回应验证质询
质询是一个带签名的 POST,其中包含一个保留事件名称和一个 nonce。请返回 200,并附上仅对 nonce 计算得出的 HMAC-SHA256 值,以小写十六进制表示。
{ "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. 快速确认接收并异步处理
每次投递都是一个 POST,连接超时和读取超时均为 5 秒,且只尝试 1 次。没有重试、退避或重放。如果您的处理程序耗时 6 秒,或者您的服务器宕机 2 分钟,这些事件就会丢失。只有 2xx 才算投递成功;3xx、4xx 和 5xx 均视为失败。
因此,请求处理流程只执行 3 件事:验证签名,将原始请求体写入队列或表并等待提交完成,然后返回 200。数据补充、再次调用 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 概述、签名与验证、投递行为、事件参考。