Signatures et vérification
Vérifiez l’en-tête X-ListingsAPI-Signature, répondez au défi de vérification du point de terminaison et évitez la confusion entre base64 et hex.
Votre URL de webhooks est un point de terminaison HTTPS public, donc toute personne sur Internet peut y envoyer une requête POST. Le secret de signature vous permet de distinguer un envoi authentique d’un envoi falsifié : chaque requête signée contient un HMAC calculé avec un secret que seuls vous et la plateforme détenez.
Deux mécanismes distincts utilisent ce secret et peuvent facilement être confondus :
L’en-tête de signature
Chaque envoi signé contient :
X-ListingsAPI-Signature: sha256=<digest> digest = base64( HMAC-SHA256(secret, raw_request_body) )Le préfixe sha256= fait partie de la valeur de l’en-tête. Incluez-le lorsque vous construisez la chaîne attendue, ou comparez uniquement la partie située après =. Rien n’est ajouté au début du corps avant le hachage : ni horodatage, ni version, ni chaîne de signature concaténée. Le HMAC porte uniquement sur le corps brut.
Vérification étape par étape
- Lisez les octets du corps brut de la requête, exactement tels qu’ils ont été reçus. N’analysez pas le JSON pour le sérialiser à nouveau. L’ordre des clés, les espaces et l’échappement Unicode modifient tous les octets, et toute modification invalide le HMAC. La plupart des frameworks doivent être configurés pour conserver le corps brut. Consultez les exemples ci-dessous.
- Calculez
HMAC-SHA256(your_secret, raw_body), encodez le résultat en base64 et ajoutez le préfixesha256=. - Comparez le résultat à l’en-tête à l’aide d’une comparaison en temps constant (
crypto.timingSafeEqual,hmac.compare_digest). Une simple comparaison avec==révèle, par sa durée d’exécution, des informations sur le nombre d’octets identiques au début des deux valeurs. - En cas de différence, renvoyez
401et ne traitez rien.
Vérification du point de terminaison
Avant que les événements réels soient transmis, votre point de terminaison doit prouver, une seule fois, qu’il détient bien le secret. Cela empêche l’envoi continu de charges utiles signées à une URL mal saisie ou appartenant à une personne incapable d’en valider la signature.
Vous déclenchez la vérification depuis la page Webhooks du tableau de bord. La plateforme envoie un défi signé et attend la réponse pendant 10 secondes au maximum. (Ces 10 secondes s’appliquent uniquement à l’échange de vérification ; le délai d’expiration des envois réels est de 5 secondes.)
Le contrat du défi de vérification
Le défi de vérification est une requête signée ordinaire, avec un nom d’événement réservé :
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Votre endpoint doit :
- Vérifier
X-ListingsAPI-Signaturecomme pour toute réception. - Reconnaître
event == "endpoint.verification"et effectuer le traitement correspondant avant le traitement habituel des événements. - Calculer
HMAC-SHA256(secret, nonce)sous forme de chaîne hexadécimale en minuscules. Le HMAC porte uniquement sur le nonce, et non sur le corps de la requête. - Renvoyer
200avec cette chaîne hexadécimale comme corps de réponse. Une chaîne hexadécimale seule est attendue ; une valeur hexadécimale entre guillemets ou encodée sous forme de chaîne JSON est également acceptée.
Tout autre résultat (un code hors de la plage 2xx, une empreinte incorrecte, un dépassement du délai ou un hôte inaccessible) fait échouer la vérification.
Ce qui réinitialise la vérification
La vérification n’est pas permanente. Le compte repasse à l’état non vérifié et les envois sont suspendus dans chacun des cas suivants :
- Vous modifiez l’URL des webhooks ou régénérez le secret. L’ancienne preuve ne s’applique plus à la nouvelle configuration.
- Une tentative de vérification échoue : cela inclut un endpoint qui fonctionnait auparavant, mais dont le nom d’hôte ne peut plus être résolu ou qui ne répond plus. Relancer une vérification sur un endpoint défaillant désactivera donc les envois ; ce comportement est intentionnel.
Relancez la vérification une fois l’endpoint de nouveau opérationnel pour reprendre les envois.
Exemples fonctionnels de récepteurs
Les deux exemples remplissent les trois fonctions : conserver le corps brut, vérifier la signature en temps constant et répondre au défi de vérification. Ils lisent tous deux un seul secret depuis l’environnement : il existe exactement un secret valide par compte et aucune période de chevauchement, donc ne créez pas de boucle qui accepte l’un ou l’autre de deux secrets.
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}, 200Vecteurs de test
Des entrées fixes et les sorties exactes produites par une implémentation correcte. Intégrez-les à un test unitaire avant de diriger un envoi réel vers votre endpoint.
Secret (manifestement factice, réservé aux tests) :
whsec_test_0123456789abcdef0123456789abcdefCorps brut de la requête, avec exactement ces octets :
{"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"}}Signature de requête attendue, en base64, avec le préfixe sha256= :
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=Pour le défi de vérification, avec le même secret et ce nonce :
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3cvotre endpoint doit renvoyer 200 avec exactement ce corps hexadécimal en minuscules :
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Pour reproduire les deux résultats, enregistrez la ligne du corps ci-dessus dans body.json sans saut de ligne final (printf '%s' '...' > body.json), puis :
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())Si la première ligne ne correspond pas à la valeur d’en-tête ci-dessus, ce sont vos octets qui sont incorrects, pas votre HMAC. Vérifiez d’abord la longueur du corps.
Suivant
- Modalités de livraison : la charge utile standard, le délai imparti de 5 secondes et chaque statut de livraison.
- Référence des événements : les 18 événements et leurs charges utiles.