簽章與驗證
驗證 X-ListingsAPI-Signature 標頭、回應端點驗證挑戰,並避免混淆 base64 與 hex 編碼。
您的 webhooks URL 是公開的 HTTPS 端點,因此網際網路上的任何人都能向它POST。簽章密鑰可讓您辨別真正的傳送與偽造的傳送:每個已簽署的請求都帶有 HMAC,這個 HMAC 是使用只有您與平台持有的密鑰計算而得。
有 2 種不同的機制會使用該密鑰,而且很容易混淆:
簽章標頭
每次已簽署的傳送都帶有:
X-ListingsAPI-Signature: sha256=<digest> digest = base64( HMAC-SHA256(secret, raw_request_body) )sha256= 前綴是標頭值的一部分。建立預期字串時請包含此前綴,或僅比對 = 之後的部分。雜湊計算前不會在主體前面加上任何內容:沒有時間戳記、版本或串接的簽章字串。HMAC 僅涵蓋原始主體,不包含其他內容。
驗證步驟
- 讀取原始請求主體的位元組,完整保留接收時的內容。請勿解析 JSON 後再重新序列化。鍵的順序、空白字元和 Unicode 跳脫方式都會改變位元組,而任何變更都會導致 HMAC 驗證失敗。大多數框架都需要設定才能保留原始主體。請參閱下方範例。
- 計算
HMAC-SHA256(your_secret, raw_body),將結果以 base64 編碼,並加上前綴sha256=。 - 使用固定時間比對(
crypto.timingSafeEqual、hmac.compare_digest)與標頭進行比對。一般的==會洩漏時間資訊,透露開頭有多少位元組相符。 - 若不相符,請傳回
401,且不進行任何處理。
端點驗證
開始傳送實際事件前,您的端點必須證明它確實持有密鑰,只需驗證一次。這可避免持續將已簽署的承載資料傳送至輸入錯誤的 URL,或傳送至屬於無法驗證這些資料之人員的 URL。
您可以從儀表板的 Webhooks 頁面觸發驗證。平台會傳送已簽署的挑戰,並等待回覆,最長 10 秒。(這 10 秒僅適用於交握;實際傳送的逾時時間為 5 秒。)
驗證挑戰規格
驗證挑戰是一般的已簽署請求,使用保留的事件名稱:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}您的端點必須:
- 依照驗證任何傳送請求的方式驗證
X-ListingsAPI-Signature。 - 識別
event == "endpoint.verification",並在一般事件處理之前進入對應的處理分支。 - 計算
HMAC-SHA256(secret, nonce),並以小寫十六進位字串表示。HMAC 僅針對 nonce 計算,而非針對請求主體。 - 回傳
200,並以該十六進位字串作為回應主體。預期格式為純十六進位字串;也接受加上引號或包裝為 JSON 字串的十六進位值。
任何其他情況(非 2xx 狀態碼、摘要錯誤、逾時、無法連線至主機)都會導致驗證失敗。
哪些情況會重設驗證狀態
驗證並非永久有效。每當發生以下情況時,帳戶就會回到未驗證狀態,且傳送會暫停:
- 您變更 webhooks URL,或重新產生密鑰。舊的驗證證明不再適用於新設定。
- 驗證嘗試失敗:包括先前運作正常,但後來已無法解析或不再回應的端點。因此,對故障端點重新執行驗證會停用傳送;這是預期的設計行為。
端點恢復正常後,請重新驗證以恢復傳送。
可運作的接收端範例
這 2 個範例都會完成 3 項工作:保留原始主體、以固定時間驗證簽章,以及回應驗證挑戰。兩者都會從環境變數讀取 1 個密鑰:每個帳戶恰好只有 1 個有效密鑰,且沒有新舊密鑰同時有效的期間,因此請勿建立可接受 2 個密鑰中任一個的迴圈。
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 });});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測試向量
固定的輸入,以及正確實作所產生的精確輸出。請先將這些測試向量加入單元測試,再將實際傳送導向您的端點。
密鑰(明顯為假,僅供測試使用):
whsec_test_0123456789abcdef0123456789abcdef原始請求主體,必須與以下位元組完全相同:
{"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= 前綴:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=對於驗證挑戰,使用相同的密鑰及以下 nonce 時:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c您的端點必須回傳 200,且主體必須與以下小寫十六進位內容完全相同:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145若要重現這 2 個結果,請將上方的主體行儲存至 body.json,且結尾不得有換行字元(printf '%s' '...' > body.json),然後執行:
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 有誤。請先檢查主體長度。