Réagir aux événements avec les webhooks
Configurez l’URL webhook, validez le défi, vérifiez chaque signature, répondez sous 5 s et récupérez les événements manqués par interrogation périodique.
Les webhooks envoient une requête HTTP POST à votre serveur lorsqu'un changement survient sur votre compte : un avis arrive, une fiche est mise en ligne, une publication est publiée. Il existe une seule URL par compte et aucun abonnement par événement ; un seul récepteur reçoit donc les 18 événements documentés et adapte le traitement selon le champ event.
1. Définir l'URL et le secret dans le tableau de bord
Commencez par créer le récepteur décrit aux étapes 2 et 3. La génération d'un secret rend la signature obligatoire et suspend l'envoi jusqu'à ce que votre endpoint réussisse la vérification ; un endpoint qui n'est pas prêt bloque donc tous les événements.
- Déployez l'endpoint HTTPS. Les adresses privées sont refusées et les redirections ne sont pas suivies ; enregistrez donc l'URL finale sans redirection
wwwni redirection liée à une barre oblique finale avant d'y accéder. 2. Enregistrez l'URL sur la page Webhooks du tableau de bord. 3. Générez un secret de signature. Il n'est affiché qu'une seule fois. Stockez-le sousLISTINGSAPI_WEBHOOK_SECRETsur votre serveur. 4. Cliquez sur Vérifier l'endpoint. La plateforme envoie le défi de l'étape 2 et attend jusqu'à 10 secondes.
La modification de l'URL ou la régénération du secret réinitialise la vérification ; déployez donc d'abord le nouveau secret, puis relancez la vérification. Vous trouverez les détails dans Présentation des webhooks.
2. Répondre au défi de vérification
Le défi est une requête POST signée contenant un nom d'événement réservé et un nonce. Renvoyez 200 avec le HMAC-SHA256 du nonce seul, au format hexadécimal en minuscules.
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}3. Vérifier la signature à chaque envoi
Chaque requête signée contient X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. Calculez la même empreinte sur les octets exacts que vous avez reçus, comparez les empreintes en temps constant et renvoyez 401 si elles ne correspondent pas. N'analysez pas le JSON pour le sérialiser à nouveau au préalable ; cela modifie les octets. Les deux récepteurs ci-dessous conservent le corps brut, vérifient la signature, répondent au défi et stockent l'événement de manière durable avant de répondre.
import base64import hashlibimport hmacimport os from flask import Flask, abort, request app = Flask(__name__)SECRET = os.environ["LISTINGSAPI_WEBHOOK_SECRET"].encode() 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) @app.post("/webhooks/listingsapi")def webhooks(): raw = request.get_data() # raw bytes, exactly as received if not signature_is_valid(raw, request.headers.get("X-ListingsAPI-Signature")): abort(401) body = request.get_json(silent=True) or {} # One-time handshake: hex digest of the nonce, not the body. if body.get("event") == "endpoint.verification": return hmac.new(SECRET, body["nonce"].encode(), hashlib.sha256).hexdigest(), 200 # Real event: durable write first, then acknowledge. Swap for your queue. inbox.put(raw) # must be committed before the 200 goes out return {"received": True}, 200import crypto from 'crypto';import express from 'express'; const app = express();const SECRET = process.env.LISTINGSAPI_WEBHOOK_SECRET as string; // express.raw keeps req.body as the untouched Buffer the HMAC covers.app.post('/webhooks/listingsapi', express.raw({ type: '*/*' }), async (req, res) => {const raw: Buffer = req.body;const digest = crypto.createHmac('sha256', SECRET).update(raw).digest('base64');const expected = Buffer.from('sha256=' + digest);const given = Buffer.from(req.get('X-ListingsAPI-Signature') ?? '');if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) { return res.status(401).send('bad signature');} const body = JSON.parse(raw.toString('utf8')); // One-time handshake: hex digest of the nonce, not the body.if (body.event === 'endpoint.verification') { const answer = crypto.createHmac('sha256', SECRET).update(body.nonce).digest('hex'); return res.status(200).send(answer);} // Real event: durable write first, then acknowledge. Swap for your queue.await inbox.put(raw); // must be committed before the 200 goes outreturn res.status(200).json({ received: true });});Si aucun secret n'a jamais été généré pour un compte, les envois arrivent sans signature. Ne vous appuyez pas sur ce mode ; générez le secret une fois le récepteur ci-dessus en service.
4. Accuser réception rapidement et traiter de manière asynchrone
Un envoi correspond à une seule requête POST, avec un délai d'expiration de 5 secondes pour la connexion et de 5 secondes pour la lecture, et fait l'objet d'exactement une tentative. Il n'y a aucune nouvelle tentative, aucun délai progressif entre tentatives et aucune relecture. Si votre gestionnaire prend 6 secondes ou si votre serveur est indisponible pendant 2 minutes, ces événements sont perdus. Seul un 2xx indique que l'envoi a réussi ; 3xx, 4xx et 5xx sont des échecs.
Le traitement de la requête se limite donc à 3 opérations : vérifier la signature, écrire le corps brut dans une file d'attente ou une table et attendre la validation de l'écriture, puis renvoyer 200. L'enrichissement, les appels à l'API REST et toute opération faisant intervenir un tiers sont exécutés à partir de la file d'attente. Tous les événements, à l'exception de listing.submission, partagent la même structure de charge utile :
{ "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" }}location_id est une chaîne numérique, sauf pour les événements interaction.*, où sa valeur est transmise sans normalisation et peut être null. listing.submission ne contient ni timestamp ni account_id et place status et live_link au premier niveau. Les champs facultatifs de data sont omis plutôt qu'envoyés avec la valeur null. Renvoyez 200 pour tout event que vous ne reconnaissez pas ; les familles non documentées partagent la même URL.
5. Dédupliquer et gérer les livraisons dans le désordre
Vous pouvez recevoir deux fois le même événement, et vous recevrez de nombreuses fois le même id de ressource : interaction.review se déclenche à nouveau avec le même id d'interaction chaque fois que l'avis change, et aucune charge utile ne contient de version. Ne supprimez donc jamais un événement sur la seule base de l'id et du nom de l'événement, sinon vous perdrez de véritables modifications. Détectez les doublons exacts en calculant l'empreinte du corps brut et en ignorant toute répétition dans une courte fenêtre temporelle, puis utilisez l'id de la ressource comme clé d'écriture afin qu'une répétition produise le même résultat.
import hashlibimport time SEEN: dict[str, float] = {} # use Redis with a TTL in productionWINDOW_SECONDS = 600 def is_duplicate(raw_body: bytes) -> bool: key = hashlib.sha256(raw_body).hexdigest() now = time.time() for k, seen_at in list(SEEN.items()): if now - seen_at > WINDOW_SECONDS: del SEEN[k] if key in SEEN: return True SEEN[key] = now return FalseL'ordre n'est pas garanti non plus. Les événements proviennent de pipelines indépendants : local_post.published peut donc arriver avant local_post.created, et timestamp reçoit son horodatage lors de la préparation de la livraison, et non au moment où le changement a eu lieu. Lorsque l'ordre compte, relisez la ressource via l'API REST et fiez-vous à cette lecture. profile.updated est également limité à un événement par établissement par période de 60 secondes, de sorte qu'une relecture à la réception ne voit que la première modification ; relisez la ressource après la fin de cette fenêtre.
6. Associer les événements aux autres guides
Aiguillez le traitement selon event et transmettez chaque événement au code que vous avez écrit pour le guide correspondant. Le webhook indique quand ; le point de terminaison décrit dans ce guide indique quoi.
Les tableaux des champs et un exemple de charge utile pour chaque événement figurent dans la référence des événements.
7. Récupérer les données d'un événement manqué par interrogation périodique
Comme aucune nouvelle tentative n'est effectuée, chaque famille nécessite une lecture faisant autorité sur laquelle vous pouvez vous rabattre. Effectuez un rapprochement à intervalles réguliers, et pas seulement lorsqu'un problème semble survenir, et n'écrivez jamais un zéro ou « aucun changement » au motif qu'un événement n'est pas arrivé.
Le tableau de bord enregistre chaque tentative avec un statut. success indique que la livraison a réussi ; un code HTTP, timeout, network_error ou error signifie que votre point de terminaison a été atteint et a échoué ; blocked_url, endpoint_unverified et rate_limited signifient qu'aucune requête n'a été effectuée, de sorte que la correction concerne la configuration ou le forfait, et non le code. La liste complète figure dans Comportement de livraison.
Étapes suivantes
- Récupérer les avis : la première famille mise en œuvre par la plupart des intégrations, avec le balayage agrégé comme filet de sécurité.
- Vérifier l’état des fiches et les liens actifs : ce que
listing.submissionannonce. - Référence : Vue d’ensemble des webhooks, Signatures et vérification, Comportement de livraison, Référence des événements.