Signaturen und Verifizierung
Prüfen Sie den Header X-ListingsAPI-Signature, beantworten Sie die Endpoint-Verifizierungsanfrage und verwechseln Sie base64 nicht mit hex.
Ihre Webhook-URL ist ein öffentlicher HTTPS-Endpunkt, sodass jeder im Internet POST-Anfragen dorthin senden kann. Mit dem Signaturgeheimnis können Sie eine echte Zustellung von einer Fälschung unterscheiden: Jede signierte Anfrage enthält einen HMAC, der mit einem Geheimnis berechnet wurde, das nur Ihnen und der Plattform bekannt ist.
Zwei separate Mechanismen verwenden dieses Geheimnis und lassen sich leicht verwechseln:
Der Signatur-Header
Jede signierte Zustellung enthält:
X-ListingsAPI-Signature: sha256=<digest> digest = base64( HMAC-SHA256(secret, raw_request_body) )Das Präfix sha256= ist Teil des Header-Werts. Beziehen Sie es beim Erstellen der erwarteten Zeichenfolge ein, oder vergleichen Sie nur den Teil nach =. Vor der Hashberechnung wird dem Body nichts vorangestellt: kein Zeitstempel, keine Version, keine zusammengesetzte Signaturzeichenfolge. Der HMAC wird ausschließlich über den unveränderten Body berechnet.
Verifizierung Schritt für Schritt
- Lesen Sie die Bytes des Anfrage-Bodys unverändert ein, genau wie empfangen. Parsen Sie das JSON nicht, um es anschließend erneut zu serialisieren. Die Reihenfolge der Schlüssel, Leerraum und Unicode-Escaping verändern die Bytes, und jede Änderung macht den HMAC ungültig. Die meisten Frameworks müssen so konfiguriert werden, dass sie den unveränderten Body aufbewahren. Siehe die Beispiele unten.
- Berechnen Sie
HMAC-SHA256(your_secret, raw_body), kodieren Sie das Ergebnis mit base64 und stellen Siesha256=voran. - Vergleichen Sie das Ergebnis mit dem Header mithilfe eines Vergleichs mit konstanter Laufzeit (
crypto.timingSafeEqual,hmac.compare_digest). Ein einfacher Vergleich mit==gibt über seine Laufzeit preis, wie viele Bytes am Anfang übereinstimmen. - Geben Sie bei einer Abweichung
401zurück und verarbeiten Sie nichts.
Endpunktverifizierung
Bevor tatsächliche Ereignisse zugestellt werden, muss Ihr Endpunkt einmalig nachweisen, dass er das Geheimnis tatsächlich besitzt. Dadurch wird verhindert, dass fortlaufend signierte Nutzdaten an eine falsch eingegebene URL oder an eine URL gesendet werden, deren Besitzer diese Daten nicht validieren kann.
Sie lösen die Verifizierung auf der Webhooks-Seite des Dashboards aus. Die Plattform sendet eine signierte Challenge und wartet bis zu 10 Sekunden auf die Antwort. (Diese 10 Sekunden gelten nur für den Handshake; bei tatsächlichen Zustellungen beträgt das Zeitlimit 5 Sekunden.)
Vorgaben für die Challenge
Die Challenge ist eine gewöhnliche signierte Anfrage mit einem reservierten Ereignisnamen:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Ihr Endpunkt muss:
X-ListingsAPI-Signaturewie bei jeder anderen Zustellung prüfen.event == "endpoint.verification"erkennen und vor der normalen Ereignisverarbeitung in die entsprechende Verarbeitung verzweigen.HMAC-SHA256(secret, nonce)als Hexadezimalzeichenfolge mit Kleinbuchstaben berechnen. Der HMAC wird nur über die Nonce berechnet, nicht über den Anfrage-Body.200zurückgeben und diese Hexadezimalzeichenfolge als Antwort-Body verwenden. Erwartet wird eine reine Hexadezimalzeichenfolge; ein Hexadezimalwert in Anführungszeichen oder als JSON-Zeichenfolge wird ebenfalls akzeptiert.
Alles andere (ein Statuscode außerhalb von 2xx, ein falscher Hashwert, eine Zeitüberschreitung, ein nicht erreichbarer Host) lässt die Verifizierung fehlschlagen.
Was die Verifizierung zurücksetzt
Die Verifizierung ist nicht dauerhaft. Das Konto wird wieder als nicht verifiziert eingestuft und die Zustellungen werden ausgesetzt, sobald:
- Sie die Webhook-URL ändern oder das Geheimnis neu generieren. Der bisherige Nachweis gilt nicht mehr für die neue Konfiguration.
- Ein Verifizierungsversuch fehlschlägt: Das gilt auch für einen Endpunkt, der zuvor funktioniert hat und dessen Hostname inzwischen nicht mehr aufgelöst werden kann oder der nicht mehr antwortet. Eine erneute Verifizierung eines fehlerhaften Endpunkts schaltet daher die Zustellungen ab; dieses Verhalten ist beabsichtigt.
Verifizieren Sie den Endpunkt erneut, sobald er wieder ordnungsgemäß funktioniert, um die Zustellungen fortzusetzen.
Funktionsfähige Empfänger
Beide Beispiele erfüllen alle drei Aufgaben: Sie bewahren den unveränderten Body auf, prüfen die Signatur in konstanter Zeit und beantworten die Verifizierungs-Challenge. Beide lesen ein Geheimnis aus der Umgebung: Pro Konto gibt es genau ein gültiges Geheimnis und keinen Zeitraum, in dem beide Geheimnisse gültig sind. Implementieren Sie daher keine Schleife, die eines von zwei Geheimnissen akzeptiert.
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}, 200Testvektoren
Feste Eingaben und die exakten Ausgaben, die eine korrekte Implementierung erzeugt. Binden Sie diese in einen Unit-Test ein, bevor Sie eine echte Zustellung an Ihren Endpunkt senden.
Geheimnis (offensichtlich erfunden, nur für Tests):
whsec_test_0123456789abcdef0123456789abcdefUnveränderter Anfrage-Body, exakt diese Bytes:
{"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"}}Erwartete Anfragesignatur, Base64-kodiert, mit dem Präfix sha256=:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=Für die Verifizierungs-Challenge mit demselben Geheimnis und dieser Nonce:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3cmuss Ihr Endpunkt 200 zurückgeben und exakt diese Hexadezimalzeichenfolge mit Kleinbuchstaben als Antwort-Body verwenden:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Um beide Ergebnisse zu reproduzieren, speichern Sie die obige Body-Zeile ohne abschließenden Zeilenumbruch in body.json (printf '%s' '...' > body.json) und führen Sie dann Folgendes aus:
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())Wenn die erste Zeile nicht mit dem obigen Header-Wert übereinstimmt, sind Ihre Bytes falsch, nicht Ihr HMAC. Prüfen Sie zuerst die Länge des Bodys.
Weiter
- Zustellverhalten: die Standardnutzdaten, das Zeitbudget von 5 Sekunden und jeder Zustellstatus.
- Ereignisreferenz: alle 18 Ereignisse und ihre Nutzdaten.