Reagisci agli eventi con i webhook

Configura l'URL dei webhook, supera la challenge, verifica ogni firma, conferma la ricezione entro 5 secondi e recupera gli eventi persi via polling.

I webhook inviano una richiesta HTTP POST al tuo server quando qualcosa cambia nel tuo account: arriva una recensione, una scheda diventa visibile online, un post viene pubblicato. C'è un solo URL per account e non sono previste sottoscrizioni per singolo evento, quindi un unico ricevitore riceve tutti i 18 eventi documentati e li gestisce in base al campo event.

1. Imposta l'URL e il segreto nella dashboard

Crea prima il ricevitore descritto nei passaggi 2 e 3. La generazione di un segreto rende obbligatoria la firma e sospende l'invio finché il tuo endpoint non supera la verifica, quindi un endpoint non pronto blocca tutti gli eventi.

  1. Distribuisci l'endpoint HTTPS. Gli indirizzi privati vengono rifiutati e i reindirizzamenti non vengono seguiti, quindi salva l'URL finale senza un reindirizzamento www o per la barra finale che lo preceda. 2. Salva l'URL nella pagina Webhook della dashboard. 3. Genera un segreto di firma. Viene mostrato una sola volta. Memorizzalo come LISTINGSAPI_WEBHOOK_SECRET sul tuo server. 4. Fai clic su Verifica endpoint. La piattaforma invia il challenge del passaggio 2 e attende fino a 10 secondi.

La modifica dell'URL o la rigenerazione del segreto azzera la verifica, quindi distribuisci prima il nuovo segreto, poi esegui nuovamente la verifica. Trovi i dettagli in Panoramica dei webhook.

2. Rispondi al challenge di verifica

Il challenge è una richiesta POST firmata con un nome di evento riservato e un nonce. Rispondi con 200 e l'HMAC-SHA256 del solo nonce, in formato esadecimale con lettere minuscole.

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

3. Verifica la firma a ogni invio

Ogni richiesta firmata contiene X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. Calcola lo stesso digest sugli esatti byte ricevuti, confrontalo in tempo costante e restituisci 401 se non corrisponde. Non eseguire prima il parsing e la riserializzazione del JSON; questo modifica i byte. Entrambi i ricevitori riportati di seguito conservano il corpo grezzo, verificano la firma, rispondono al challenge e memorizzano l'evento in modo persistente prima di rispondere.

Flask receiver
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}, 200
Express receiver
import 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 });});

Se un account non ha mai generato un segreto, le richieste arrivano senza firma. Non basare la tua implementazione su questa modalità; genera il segreto non appena il ricevitore riportato sopra è operativo.

4. Conferma rapidamente la ricezione ed elabora in modo asincrono

Un invio consiste in una singola richiesta POST con un timeout di connessione di 5 secondi e un timeout di lettura di 5 secondi, tentata esattamente una volta. Non sono previsti nuovi tentativi, backoff o replay. Se il tuo gestore impiega sei secondi o il tuo server non è disponibile per due minuti, quegli eventi sono persi. Solo una risposta 2xx fa considerare l'invio riuscito; 3xx, 4xx e 5xx sono considerati errori.

Quindi il percorso di gestione della richiesta fa solo tre cose: verifica la firma, scrive il corpo grezzo in una coda o in una tabella e attende il commit, restituisce 200. L'arricchimento dei dati, le chiamate all'API REST e qualsiasi operazione che coinvolga terze parti vengono eseguiti a partire dalla coda. Tutti gli eventi tranne listing.submission condividono la stessa struttura del payload:

JSON
{  "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 è una stringa numerica, tranne negli eventi interaction.*, in cui viene trasmesso senza normalizzazione e può essere null. listing.submission non contiene né timestamp né account_id e colloca status e live_link al livello principale. I campi facoltativi in data vengono omessi anziché essere inviati come null. Restituisci 200 per qualsiasi event che non riconosci; le famiglie non documentate condividono lo stesso URL.

5. Elimina i duplicati e gestisci la consegna fuori ordine

Puoi ricevere lo stesso evento due volte e riceverai lo stesso id di risorsa molte volte: interaction.review viene generato di nuovo con lo stesso id dell'interazione ogni volta che la recensione cambia e nessun payload contiene una versione. Quindi non scartare mai gli eventi in base alla combinazione di id e nome dell'evento, altrimenti perderai modifiche effettive. Rileva i duplicati esatti calcolando l'hash del corpo grezzo e ignorando le ripetizioni entro un breve intervallo, quindi usa l'id della risorsa come chiave per la scrittura, in modo che una ripetizione produca lo stesso risultato.

Exact-duplicate check
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 False

Nemmeno l'ordine è garantito. Gli eventi provengono da pipeline indipendenti, quindi local_post.published può arrivare prima di local_post.created e timestamp viene impostato quando viene preparata la consegna, non quando si è verificata la modifica. Quando l'ordine conta, rileggi la risorsa tramite l'API REST e considera autorevole quella lettura. Inoltre, profile.updated è soggetto a debounce, con un limite di un evento per sede ogni 60 secondi, quindi una rilettura all'arrivo rileva solo la prima modifica; leggi di nuovo al termine dell'intervallo.

Dirama la logica in base a event e passa ogni evento al codice che hai scritto per la guida corrispondente. Il webhook indica quando; l'endpoint in quella guida indica cosa.

EventoCosa è successoGuida da leggere
profile.createdÈ stata creata una sede. data.source è user, api o bulk_edit.Aggiungi una sede
profile.updatedÈ stata modificata una sede. data.changed_fields è un'indicazione, non un diff completo.Aggiorna una sede
profile.deletedÈ stata archiviata una sede.Trova e archivia le sedi
listing.submissionUn invio a una piattaforma di pubblicazione ha raggiunto un esito: status è success, incomplete o canceled.Controlla lo stato delle schede e i link alle schede pubblicate
connection.location_connected, connection.location_disconnectedÈ stata collegata o scollegata una scheda Google o Facebook.Collega Google e importa le sedi, Collega le Pagine Facebook
connection.reauth_required, connection.listing_inaccessibleLe credenziali sono scadute oppure è stato perso l'accesso a una scheda.Collega Google e importa le sedi
connection.google_verification_verified, connection.google_verification_failedUna verifica di Google si è conclusa.Crea un Google Business Profile
interaction.reviewÈ arrivata una recensione oppure una recensione è stata modificata. I campi si trovano sotto data.interaction.Recupera le recensioni
interaction.responseUna risposta del proprietario è stata pubblicata, modificata o archiviata. parent_id è la recensione.Rispondi alle recensioni
local_post.created, local_post.published, local_post.rejected, local_post.deletedUn elemento di un post ha cambiato stato. Usa data.post_item_id come chiave, uno per sede.Pubblica post
review_analytics.daily_snapshot, review_analytics.weekly_snapshotÈ stata eseguita l'aggregazione delle recensioni per una sede e un periodo.Analisi delle recensioni

Le tabelle dei campi e un payload di esempio per ogni evento si trovano nella documentazione di riferimento degli eventi.

7. Recupera un evento mancato tramite polling

Poiché non sono previsti nuovi tentativi, ogni famiglia ha bisogno di una lettura autorevole a cui puoi ricorrere. Esegui la riconciliazione a intervalli programmati, non solo quando qualcosa sembra non funzionare, e non scrivere mai uno zero o "nessuna modifica" perché un evento non è arrivato.

FamigliaRileggi conIntervallo
listing.submissionGET /locations/{locationId}/listings/premium per syncStatus e listingUrl.Ogni giorno per sede.
profile.*GET /locations oppure GET /locations/search; updatedAt mostra cosa è cambiato.Ogni ora, oltre che 60 secondi dopo ogni profile.updated.
connection.*GET /connected-accounts e googleVerificationStatus a ogni lettura di una sede.Ogni ora; ogni pochi minuti durante una verifica.
interaction.*GET /rollup_interactions per l'intero account, GET /reviewDetails per id.Ogni 15 minuti per le nuove recensioni, ogni giorno per un controllo completo.
local_post.*GET /posts/{postId} oppure GET /locations/{locationId}/posts.Ogni ora mentre i post sono in fase di elaborazione.
review_analytics.*GET /locations/{locationId}/review-analytics-overview con le date del periodo.Ogni giorno dopo l'aggregazione.

La dashboard registra ogni tentativo con uno stato. success indica che la consegna è avvenuta; un codice HTTP, timeout, network_error o error indica che il tuo endpoint è stato raggiunto e ha riscontrato un errore; blocked_url, endpoint_unverified e rate_limited indicano che non è stata effettuata alcuna richiesta, quindi la soluzione riguarda la configurazione o il piano, non il codice. L'elenco completo si trova in Comportamento della consegna.

Passaggi successivi