簽章與驗證

驗證 X-ListingsAPI-Signature 標頭、回應端點驗證挑戰,並避免混淆 base64 與 hex 編碼。

您的 webhooks URL 是公開的 HTTPS 端點,因此網際網路上的任何人都能向它POST。簽章密鑰可讓您辨別真正的傳送與偽造的傳送:每個已簽署的請求都帶有 HMAC,這個 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,或重新產生密鑰。舊的驗證證明不再適用於新設定。
  • 驗證嘗試失敗:包括先前運作正常,但後來已無法解析或不再回應的端點。因此,對故障端點重新執行驗證會停用傳送;這是預期的設計行為。

端點恢復正常後,請重新驗證以恢復傳送。

可運作的接收端範例

這 2 個範例都會完成 3 項工作:保留原始主體、以固定時間驗證簽章,以及回應驗證挑戰。兩者都會從環境變數讀取 1 個密鑰:每個帳戶恰好只有 1 個有效密鑰,且沒有新舊密鑰同時有效的期間,因此請勿建立可接受 2 個密鑰中任一個的迴圈。

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

若要重現這 2 個結果,請將上方的主體行儲存至 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())

如果第 1 行與上方的標頭值不符,表示位元組有誤,而非 HMAC 有誤。請先檢查主體長度。

下一步

  • 傳送行為:標準承載資料、5 秒的時間限制,以及所有傳送狀態。
  • 事件參考:全部 18 種事件及其承載資料。