Reacciona a los eventos con webhooks

Fija tu URL de webhooks, supera el reto de verificación, valida cada firma, confirma en 5 segundos y recupera eventos perdidos con consultas periódicas.

Los webhooks envían una solicitud HTTP POST a tu servidor cuando algo cambia en tu cuenta: llega una reseña, una ficha se activa o se publica una publicación. Hay una URL por cuenta y no hay suscripciones por evento, por lo que un único receptor recibe los 18 eventos documentados y decide qué lógica ejecutar según el campo event.

1. Configura la URL y el secreto en el panel

Primero, implementa el receptor de los pasos 2 y 3. Generar un secreto hace que la firma sea obligatoria y pausa las entregas hasta que tu endpoint supere la verificación, por lo que un endpoint que no esté listo bloquea todos los eventos.

  1. Despliega el endpoint HTTPS. Se rechazan las direcciones privadas y no se siguen las redirecciones, así que guarda la URL final sin ninguna redirección www ni redirección por una barra final antes de llegar a ella. 2. Guarda la URL en la página Webhooks del panel. 3. Genera un secreto de firma. Se muestra una sola vez. Almacénalo como LISTINGSAPI_WEBHOOK_SECRET en tu servidor. 4. Haz clic en Verificar endpoint. La plataforma envía el desafío del paso 2 y espera hasta 10 segundos.

Cambiar la URL o regenerar el secreto restablece la verificación, así que primero despliega el nuevo secreto y luego vuelve a verificar. Encontrarás los detalles en Descripción general de los webhooks.

2. Responde al desafío de verificación

El desafío es una solicitud POST firmada con un nombre de evento reservado y un nonce. Responde con 200 e incluye el HMAC-SHA256 calculado únicamente sobre el nonce, en hexadecimal con letras minúsculas.

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

3. Verifica la firma en cada entrega

Cada solicitud firmada incluye X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. Calcula el mismo resumen criptográfico sobre los bytes exactos que recibiste, compara en tiempo constante y devuelve 401 si no coinciden. No analices ni vuelvas a serializar el JSON antes de hacerlo; eso cambia los bytes. Los dos receptores siguientes conservan el cuerpo sin procesar, verifican la firma, responden al desafío y almacenan el evento de forma duradera antes de responder.

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 });});

Si una cuenta nunca ha generado un secreto, las entregas llegan sin firma. No bases tu implementación en ese modo; genera el secreto una vez que el receptor anterior esté en funcionamiento.

4. Confirma la recepción rápidamente y procesa de forma asíncrona

Una entrega consiste en una solicitud POST con un tiempo de espera máximo de conexión de 5 segundos y un tiempo de espera máximo de lectura de 5 segundos, que se intenta exactamente una vez. No hay reintentos, esperas progresivas ni reenvíos. Si tu manejador tarda seis segundos o tu servidor está caído durante dos minutos, esos eventos se pierden. Solo una respuesta 2xx cuenta como entrega correcta; 3xx, 4xx y 5xx son fallos.

Por tanto, el flujo de la solicitud solo hace tres cosas: verificar, escribir el cuerpo sin procesar en una cola o tabla y esperar a que se confirme la escritura, y devolver 200. El enriquecimiento de datos, las llamadas de vuelta a la API REST y cualquier operación que interactúe con un tercero se ejecutan desde la cola. Todos los eventos excepto listing.submission comparten la misma estructura de datos:

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 es una cadena numérica, excepto en los eventos interaction.*, donde se transmite sin normalizar y puede ser null. listing.submission no tiene timestamp ni account_id y coloca status y live_link en el nivel superior. Los campos opcionales de data se omiten en lugar de enviarse como null. Devuelve 200 para cualquier event que no reconozcas; las familias no documentadas comparten la misma URL.

5. Elimina duplicados y admite la entrega fuera de orden

Puedes recibir el mismo evento dos veces y recibirás el mismo id de recurso muchas veces: interaction.review vuelve a emitirse con la misma interacción id cada vez que cambia la reseña, y ningún cuerpo de datos incluye una versión. Por eso, nunca descartes eventos basándote en el id y el nombre del evento, o perderás modificaciones reales. Detecta duplicados exactos calculando un hash del cuerpo sin procesar e ignorando las repeticiones dentro de un intervalo breve; después, usa el id del recurso como clave de escritura para que una repetición produzca el mismo resultado.

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

El orden tampoco está garantizado. Los eventos proceden de flujos de procesamiento independientes, por lo que local_post.published puede llegar antes que local_post.created, y timestamp se registra cuando se prepara la entrega, no cuando se produjo el cambio. Cuando el orden importe, vuelve a consultar el recurso en la API REST y confía en esa lectura. Además, profile.updated limita las emisiones a un evento por ubicación cada 60 segundos, por lo que una consulta al recibirlo solo muestra la primera modificación; vuelve a consultar cuando termine ese intervalo.

Selecciona la rama de ejecución según event y pasa cada evento al código que escribiste para la guía correspondiente. El webhook indica cuándo; el endpoint de esa guía indica qué.

EventoQué ocurrióGuía que consultar
profile.createdSe creó una ubicación. data.source es user, api o bulk_edit.Añadir una ubicación
profile.updatedSe modificó una ubicación. data.changed_fields es una indicación, no una comparación completa de los cambios.Actualizar una ubicación
profile.deletedSe archivó una ubicación.Buscar y archivar ubicaciones
listing.submissionSe resolvió un envío a una plataforma de publicación: status es success, incomplete o canceled.Comprobar el estado de las fichas y los enlaces públicos
connection.location_connected, connection.location_disconnectedSe vinculó o desvinculó una ficha de Google o Facebook.Conectar Google e importar ubicaciones, Conectar páginas de Facebook
connection.reauth_required, connection.listing_inaccessibleLas credenciales caducaron o se perdió el acceso a una ficha.Conectar Google e importar ubicaciones
connection.google_verification_verified, connection.google_verification_failedFinalizó una verificación de Google.Crear un Google Business Profile
interaction.reviewSe recibió o modificó una reseña. Los campos están dentro de data.interaction.Obtener reseñas
interaction.responseSe publicó, modificó o archivó una respuesta del propietario. parent_id es la reseña.Responder a reseñas
local_post.created, local_post.published, local_post.rejected, local_post.deletedUn elemento de publicación cambió de estado. Usa data.post_item_id como clave, uno por ubicación.Publicar contenido
review_analytics.daily_snapshot, review_analytics.weekly_snapshotSe ejecutó la agregación de reseñas para una ubicación y un período.Analítica de reseñas

Las tablas de campos y un cuerpo de datos de ejemplo para cada evento se encuentran en la referencia de eventos.

7. Recupérate de un evento perdido mediante consultas periódicas

Como no hay reintentos, cada familia necesita una consulta a una fuente de datos fiable a la que puedas recurrir. Concilia los datos de forma programada, no solo cuando algo parezca incorrecto, y nunca escribas un cero o "sin cambios" porque no haya llegado un evento.

FamiliaVuelve a consultar conIntervalo
listing.submissionGET /locations/{locationId}/listings/premium para syncStatus y listingUrl.A diario por ubicación.
profile.*GET /locations o GET /locations/search; updatedAt muestra qué cambió.Cada hora y, además, 60 segundos después de cada profile.updated.
connection.*GET /connected-accounts y googleVerificationStatus en cualquier consulta de una ubicación.Cada hora; cada pocos minutos durante una verificación.
interaction.*GET /rollup_interactions en toda la cuenta, GET /reviewDetails por id.Cada 15 minutos para las reseñas nuevas y a diario para una comprobación completa.
local_post.*GET /posts/{postId} o GET /locations/{locationId}/posts.Cada hora mientras las publicaciones estén en curso.
review_analytics.*GET /locations/{locationId}/review-analytics-overview con las fechas del período.A diario después de la agregación.

El panel registra cada intento con un estado. success indica que se entregó; un código HTTP, timeout, network_error o error significa que se llegó a tu endpoint y se produjo un fallo; blocked_url, endpoint_unverified y rate_limited significan que no se realizó ninguna solicitud, por lo que la solución está en la configuración o el plan, no en el código. La lista completa se encuentra en Comportamiento de entrega.

Próximos pasos