签名与验证

验证 X-ListingsAPI-Signature 请求头,响应端点验证质询,并避免混淆 base64 与十六进制编码。

您的 webhook URL 是一个公开的 HTTPS 端点,因此互联网上的任何人都可以向其发送 POST 请求。签名密钥用于区分真实投递和伪造投递:每个带签名的请求都包含一个 HMAC,它使用仅由您和平台持有的密钥计算得出。

有 2 种独立机制使用该密钥,两者很容易混淆:

用途方向编码
请求签名证明每次投递都是真实的通过请求头发送给您base64
验证质询一次性证明您持有该密钥您在响应正文中将其返回hex

签名请求头

每次带签名的投递都包含:

Text
X-ListingsAPI-Signature: sha256=<digest>   digest = base64( HMAC-SHA256(secret, raw_request_body) )

sha256= 前缀是请求头值的一部分。构造预期字符串时,请包含该前缀,或者仅比较 = 后面的部分。计算哈希前,不会在正文前添加任何内容:没有时间戳、版本号,也没有拼接的签名字符串。HMAC 仅针对原始正文计算,不包含其他内容。

验证步骤

  1. 读取原始请求正文的字节,确保与接收到的完全一致。不要解析 JSON 后再重新序列化。键的顺序、空白字符和 Unicode 转义都会改变字节,任何改动都会导致 HMAC 验证失败。大多数框架需要显式配置才能保留原始正文。请参阅下方示例。
  2. 计算 HMAC-SHA256(your_secret, raw_body),使用 base64 编码,然后添加前缀 sha256=。
  3. 使用恒定时间比较(crypto.timingSafeEqual、hmac.compare_digest)将结果与请求头进行比较。普通的 == 会通过耗时信息泄露开头有多少字节匹配。
  4. 如果不匹配,返回 401,并且不进行任何处理。
接收端的检查。关键在于第 2 步:HMAC 必须基于接收到的字节计算,不能基于重新编码后的内容计算。

端点验证

在开始投递实际事件之前,您的端点必须先完成一次验证,证明其确实持有该密钥。这样可以防止持续向拼写错误的 URL,或向属于无法验证签名的接收方的 URL 发送带签名的载荷。

您可以从控制台的 Webhooks 页面触发验证。平台会发送带签名的质询,并最多等待 10 秒以接收响应。(这 10 秒仅适用于握手;实际投递的超时时间为 5 秒。)

握手过程。请注意两者的差异:质询的签名以 base64 编码放在请求头中,而您的回答则是放在响应正文中的十六进制摘要。

验证质询约定

验证质询是一个普通的已签名请求,使用保留的事件名称:

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

您的端点必须:

  1. 像处理任何投递一样验证 X-ListingsAPI-Signature。
  2. 识别 event == "endpoint.verification",并在执行常规事件处理之前进入相应分支。
  3. 计算 HMAC-SHA256(secret, nonce),结果为小写十六进制字符串。HMAC 仅针对 nonce 计算,而非针对请求体计算。
  4. 返回 200,并将该十六进制字符串作为响应体。预期的响应体是纯十六进制字符串;也接受带引号或包装为 JSON 字符串的十六进制值。

任何其他情况(非 2xx 状态码、摘要错误、超时、主机无法访问)都会导致验证失败。

哪些情况会重置验证状态

验证状态并非永久有效。每当出现以下情况时,账户都会恢复为未验证状态,投递也会暂停:

  • 您更改 webhooks URL 或重新生成密钥。原有的验证证明不再适用于新配置。
  • 验证尝试失败:这也包括端点曾经正常工作,但此后无法解析或不再响应的情况。因此,对故障端点重新执行验证会停止投递;这是预期行为。

端点恢复正常后,请重新验证以恢复投递。

可运行的接收端示例

两个示例都完成了三项工作:保留原始请求体、以恒定时间验证签名,以及响应验证质询。两者都从环境中读取一个密钥:每个账户恰好只有一个有效密钥,且没有新旧密钥同时有效的时间窗口,因此请勿编写循环来尝试接受两个密钥中的任意一个。

Node.js (Express)
const crypto = require('crypto');const express = require('express'); const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET; // Keep the raw bytes. express.json() would otherwise leave you// with only the parsed object, and re-serializing it changes the// bytes the HMAC covers.app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),); function signatureIsValid(rawBody, header) {const digest = crypto  .createHmac('sha256', SECRET)  .update(rawBody)  .digest('base64');const expected = 'sha256=' + digest; const a = Buffer.from(header || '', 'utf8');const b = Buffer.from(expected, 'utf8');// timingSafeEqual throws on a length mismatch, so check first.return a.length === b.length && crypto.timingSafeEqual(a, b);} // The only work allowed before the 200: put the raw bytes somewhere durable// and wait for the write to be acknowledged. Swap the INSERT for your real// queue or table write; it must be committed before you respond, because// nothing is retried if your process dies after the 200 goes out.async function storeForProcessing(rawBody, signature) {await db.query(  'INSERT INTO webhook_inbox (signature, body, received_at) VALUES ($1, $2, now())',  [signature, rawBody.toString('utf8')],);} app.post('/webhooks/listingsapi', async (req, res) => {const header = req.get('X-ListingsAPI-Signature');if (!signatureIsValid(req.rawBody, header)) {  return res.status(401).send('bad signature');} const body = req.body; // The verification handshake: answer with HEX, not base64.if (body.event === 'endpoint.verification') {  const answer = crypto    .createHmac('sha256', SECRET)    .update(body.nonce)    .digest('hex');  return res.status(200).send(answer);} // A real event: durable write first, acknowledge second.try {  await storeForProcessing(req.rawBody, header);} catch (err) {  // Nothing was persisted, so do not claim success.  return res.status(500).send('could not persist');}return res.status(200).json({ received: true });});
Python (Flask)
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() HEADER = "X-ListingsAPI-Signature"  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)  def store_for_processing(raw_body: bytes, signature: str) -> None:  """The only work allowed before the 200: put the raw bytes somewhere  durable and return only once the write is committed. Swap the INSERT for  your real queue or table write - nothing is retried if the process dies  after the response goes out."""  with db.begin() as tx:      tx.execute(          "INSERT INTO webhook_inbox (signature, body) VALUES (%s, %s)",          (signature, raw_body),      )  @app.post("/webhooks/listingsapi")def webhooks():  raw = request.get_data()  # raw bytes, exactly as received  header = request.headers.get(HEADER)  if not signature_is_valid(raw, header):      abort(401)   body = request.get_json(silent=True) or {}   # The verification handshake: answer with HEX, not base64.  if body.get("event") == "endpoint.verification":      nonce = body["nonce"].encode()      return hmac.new(SECRET, nonce, hashlib.sha256).hexdigest(), 200   # A real event: durable write first, acknowledge second.  try:      store_for_processing(raw, header or "")  except Exception:      # Nothing was persisted, so do not claim success.      return {"error": "could not persist"}, 500  return {"received": True}, 200

测试向量

以下是固定输入,以及正确实现应生成的确切输出。在向您的端点发送实际投递之前,请将这些测试向量加入单元测试。

密钥(显然是虚构的,仅供测试使用):

Text
whsec_test_0123456789abcdef0123456789abcdef

原始请求体,字节必须与以下内容完全一致:

Text
{"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"}}

预期的请求签名,采用 base64 编码,以 sha256= 为前缀:

Text
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=

对于验证质询,使用相同的密钥和以下 nonce:

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

您的端点必须返回 200,且响应体必须与以下小写十六进制内容完全一致:

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

要复现这两个结果,请将上面的请求体行保存到 body.json,确保末尾没有换行符(printf '%s' '...' > body.json),然后执行:

Python
import base64, hashlib, hmac secret = b"whsec_test_0123456789abcdef0123456789abcdef"body = open("body.json", "rb").read()          # the 209 bytes abovenonce = b"9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c" # request signature: base64, over the raw bodyprint("sha256=" + base64.b64encode(hmac.new(secret, body, hashlib.sha256).digest()).decode())# challenge answer: lowercase hex, over the nonce aloneprint(hmac.new(secret, nonce, hashlib.sha256).hexdigest())

如果第一行与上面的请求头值不匹配,说明您的字节有误,而非 HMAC 有误。请先检查请求体长度。

下一步

  • 投递行为:标准载荷、5 秒时限以及各个投递状态。
  • 事件参考:全部 18 个事件及其载荷。