Tanda tangan dan verifikasi
Verifikasi header X-ListingsAPI-Signature, jawab tantangan verifikasi endpoint, dan hindari kekeliruan antara base64 dan hex.
URL webhook Anda adalah endpoint HTTPS publik, sehingga siapa pun di internet dapat mengirim POST ke endpoint tersebut. Rahasia penandatanganan memungkinkan Anda membedakan pengiriman yang asli dari yang dipalsukan: setiap permintaan yang ditandatangani menyertakan HMAC yang dihitung menggunakan rahasia yang hanya dimiliki oleh Anda dan platform.
Ada dua mekanisme terpisah yang menggunakan rahasia tersebut, dan keduanya mudah tertukar:
Header tanda tangan
Setiap pengiriman yang ditandatangani menyertakan:
X-ListingsAPI-Signature: sha256=<digest> digest = base64( HMAC-SHA256(secret, raw_request_body) )Awalan sha256= adalah bagian dari nilai header. Sertakan awalan tersebut saat menyusun string yang diharapkan, atau bandingkan hanya bagian setelah =. Tidak ada apa pun yang ditambahkan di awal body sebelum penghitungan hash: tidak ada stempel waktu, versi, atau string penandatanganan yang digabungkan. HMAC hanya mencakup body mentah.
Verifikasi langkah demi langkah
- Baca byte body permintaan mentah, persis seperti yang diterima. Jangan mengurai JSON lalu menserialisasikannya kembali. Urutan kunci, spasi kosong, dan penggunaan escape Unicode semuanya mengubah byte, dan perubahan apa pun membuat HMAC tidak valid. Sebagian besar framework perlu dikonfigurasi agar menyimpan body mentah. Lihat contoh di bawah.
- Hitung
HMAC-SHA256(your_secret, raw_body), kodekan hasilnya sebagai base64, lalu tambahkan awalansha256=. - Bandingkan dengan header menggunakan perbandingan waktu konstan (
crypto.timingSafeEqual,hmac.compare_digest). Penggunaan==biasa membocorkan informasi waktu yang menunjukkan berapa banyak byte awal yang cocok. - Jika tidak cocok, kembalikan
401dan jangan proses apa pun.
Verifikasi endpoint
Sebelum pengiriman peristiwa sebenarnya dimulai, endpoint Anda harus membuktikan satu kali bahwa endpoint tersebut benar-benar memiliki rahasia itu. Langkah ini mencegah aliran payload yang ditandatangani dikirim ke URL yang salah ketik atau milik seseorang yang tidak dapat memvalidasinya.
Anda memicunya dari halaman Webhooks di dasbor. Platform mengirim tantangan yang ditandatangani dan menunggu jawaban hingga 10 detik. (Batas 10 detik tersebut hanya berlaku untuk handshake; batas waktu pengiriman sebenarnya adalah 5 detik.)
Kontrak tantangan
Tantangan ini berupa permintaan bertanda tangan biasa, dengan nama peristiwa yang dicadangkan:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Endpoint Anda harus:
- Memverifikasi
X-ListingsAPI-Signatureseperti pada setiap pengiriman lainnya. - Mengenali
event == "endpoint.verification"dan menjalankan cabang penanganan khusus sebelum penanganan peristiwa normal Anda. - Menghitung
HMAC-SHA256(secret, nonce)sebagai string heksadesimal berhuruf kecil. HMAC dihitung hanya dari nonce, bukan dari isi permintaan. - Mengembalikan
200dengan string heksadesimal tersebut sebagai isi respons. Format yang diharapkan adalah string heksadesimal tanpa pembungkus; nilai heksadesimal yang diapit tanda kutip atau dibungkus sebagai string JSON juga diterima.
Hasil lainnya (status selain 2xx, digest yang salah, batas waktu yang terlampaui, host yang tidak dapat dijangkau) menyebabkan verifikasi gagal.
Hal yang membatalkan verifikasi
Verifikasi tidak bersifat permanen. Akun kembali berstatus belum terverifikasi, dan pengiriman dijeda, setiap kali:
- Anda mengubah URL webhook, atau membuat ulang secret. Bukti lama tidak lagi berlaku untuk konfigurasi baru.
- Upaya verifikasi gagal: termasuk untuk endpoint yang sebelumnya berfungsi, tetapi kini alamatnya tidak lagi dapat diresolusikan atau tidak lagi memberikan respons. Karena itu, menjalankan ulang verifikasi terhadap endpoint yang bermasalah akan menonaktifkan pengiriman; perilaku ini memang disengaja.
Lakukan verifikasi ulang setelah endpoint berfungsi dengan baik untuk melanjutkan pengiriman.
Implementasi penerima yang berfungsi
Kedua contoh menjalankan ketiga tugas: menyimpan isi mentah, memverifikasi tanda tangan dalam waktu konstan, dan menjawab tantangan verifikasi. Keduanya membaca satu secret dari lingkungan: hanya ada satu secret yang valid per akun dan tidak ada periode ketika secret lama dan baru sama-sama berlaku, jadi jangan membuat perulangan yang menerima salah satu dari dua secret.
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}, 200Vektor pengujian
Input tetap, beserta output persis yang dihasilkan oleh implementasi yang benar. Masukkan semuanya ke dalam pengujian unit sebelum mengarahkan pengiriman nyata ke endpoint Anda.
Secret (jelas merupakan contoh palsu, hanya untuk pengujian):
whsec_test_0123456789abcdef0123456789abcdefIsi permintaan mentah, dengan byte persis berikut:
{"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"}}Tanda tangan permintaan yang diharapkan, dalam base64, dengan awalan sha256=:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=Untuk tantangan verifikasi, dengan secret yang sama dan nonce berikut:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3cendpoint Anda harus mengembalikan 200 dengan isi heksadesimal berhuruf kecil persis berikut:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Untuk menghasilkan ulang keduanya, simpan baris isi permintaan di atas ke body.json tanpa karakter baris baru di akhir (printf '%s' '...' > body.json), lalu:
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())Jika baris pertama tidak cocok dengan nilai header di atas, yang salah adalah byte Anda, bukan HMAC Anda. Periksa panjang isi permintaan terlebih dahulu.
Berikutnya
- Perilaku pengiriman: payload standar, batas waktu 5 detik, dan setiap status pengiriman.
- Referensi peristiwa: seluruh 18 peristiwa beserta payload masing-masing.