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 :

ObjectifSensEncodage
Signature de la requêteProuver que chaque envoi est authentiqueEnvoyée vers vous, dans un en-têtebase64
Défi de vérificationProuver une seule fois que vous détenez le secretVous le renvoyez en réponse, dans le corpshex

L’en-tête de signature

Chaque envoi signé contient :

Text
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

  1. 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.
  2. Calculez HMAC-SHA256(your_secret, raw_body), encodez le résultat en base64 et ajoutez le préfixe sha256=.
  3. 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.
  4. En cas de différence, renvoyez 401 et ne traitez rien.
La vérification côté destinataire. Tout repose sur la deuxième étape : le HMAC est calculé sur les octets que vous avez reçus, et non sur des données que vous avez réencodées.

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.)

L’échange de vérification. Notez l’asymétrie : la signature du défi est encodée en base64 dans un en-tête, tandis que votre réponse contient une empreinte hexadécimale dans le corps.

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é :

JSON
{  "event": "endpoint.verification",  "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c",  "timestamp": "2026-08-25T14:32:10Z"}

Votre endpoint doit :

  1. Vérifier X-ListingsAPI-Signature comme pour toute réception.
  2. Reconnaître event == "endpoint.verification" et effectuer le traitement correspondant avant le traitement habituel des événements.
  3. 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.
  4. Renvoyer 200 avec 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.

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

Vecteurs 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) :

Text
whsec_test_0123456789abcdef0123456789abcdef

Corps brut de la requête, avec exactement ces octets :

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"}}

Signature de requête attendue, en base64, avec le préfixe sha256= :

Text
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=

Pour le défi de vérification, avec le même secret et ce nonce :

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

votre endpoint doit renvoyer 200 avec exactement ce corps hexadécimal en minuscules :

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

Pour 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 :

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())

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