Reaja a eventos com webhooks

Configure a URL de webhooks, passe na verificação, valide cada assinatura, confirme em até 5 s e recupere eventos perdidos com consultas periódicas.

Os webhooks enviam uma requisição HTTP POST ao seu servidor quando algo muda na sua conta: uma avaliação chega, uma listagem entra no ar, uma publicação é publicada. Há uma URL por conta e nenhuma assinatura por evento, portanto um único receptor recebe todos os 18 eventos documentados e direciona o processamento com base no campo event.

1. Defina a URL e o segredo no painel

Primeiro, implemente o receptor das etapas 2 e 3. Gerar um segredo torna a assinatura obrigatória e pausa a entrega até que seu endpoint passe pela verificação, portanto um endpoint que não esteja pronto bloqueia todos os eventos.

  1. Coloque o endpoint HTTPS em produção. Endereços privados são recusados e redirecionamentos não são seguidos, portanto salve a URL final sem um redirecionamento www ou de barra final antes dela. 2. Salve a URL na página Webhooks do painel. 3. Gere um segredo de assinatura. Ele é exibido uma única vez. Armazene-o como LISTINGSAPI_WEBHOOK_SECRET no seu servidor. 4. Clique em Verificar endpoint. A plataforma envia o desafio da etapa 2 e aguarda até 10 segundos.

Alterar a URL ou gerar novamente o segredo redefine a verificação, portanto coloque o novo segredo em produção primeiro e depois verifique novamente. Os detalhes estão em Visão geral dos webhooks.

2. Responda ao desafio de verificação

O desafio é uma requisição POST assinada com um nome de evento reservado e um nonce. Responda com 200 e o HMAC-SHA256 apenas do nonce, em hexadecimal com letras minúsculas.

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

3. Verifique a assinatura em cada entrega

Toda requisição assinada contém X-ListingsAPI-Signature: sha256=<base64 HMAC-SHA256 of the raw body>. Calcule o mesmo resumo criptográfico sobre os bytes exatos que você recebeu, compare em tempo constante e retorne 401 se houver divergência. Não interprete e serialize novamente o JSON antes disso; isso altera os bytes. Os dois receptores abaixo preservam o corpo bruto, verificam a assinatura, respondem ao desafio e armazenam o evento de forma durável 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 });});

Se uma conta nunca gerou um segredo, as entregas chegam sem assinatura. Não desenvolva sua implementação com base nesse modo; gere o segredo assim que o receptor acima estiver em produção.

4. Confirme o recebimento rapidamente e processe de forma assíncrona

Uma entrega consiste em uma única requisição POST com um tempo limite de conexão de 5 segundos e um tempo limite de leitura de 5 segundos, com exatamente uma tentativa. Não há nova tentativa, espera progressiva nem reenvio. Se o seu manipulador levar 6 segundos ou se o seu servidor ficar indisponível por 2 minutos, esses eventos serão perdidos. Apenas uma resposta 2xx faz a entrega ser considerada concluída; 3xx, 4xx e 5xx são falhas.

Portanto, o fluxo da requisição faz apenas 3 coisas: verifica a assinatura, grava o corpo bruto em uma fila ou tabela e aguarda o commit, e retorna 200. O enriquecimento, as chamadas de volta à REST API e qualquer operação que envolva terceiros são executados a partir da fila. Todos os eventos, exceto listing.submission, compartilham a mesma estrutura de 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 é uma string numérica, exceto nos eventos interaction.*, em que é repassado sem normalização e pode ser null. listing.submission não contém timestamp nem account_id e coloca status e live_link no nível superior. Os campos opcionais em data são omitidos em vez de enviados como null. Retorne 200 para qualquer event que você não reconheça; famílias não documentadas compartilham a mesma URL.

5. Elimine duplicatas e tolere entregas fora de ordem

Você pode receber o mesmo evento duas vezes e receberá o mesmo id de recurso muitas vezes: interaction.review é disparado novamente com o mesmo id de interação sempre que a avaliação muda, e nenhum payload contém uma versão. Por isso, nunca suprima eventos com base no id combinado com o nome do evento, ou você descartará edições reais. Detecte duplicatas exatas calculando o hash do corpo bruto e ignorando repetições dentro de um intervalo curto; depois, use o id do recurso como chave da operação de gravação para que uma repetição produza o mesmo 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

A ordem também não é garantida. Os eventos vêm de fluxos de processamento independentes, então local_post.published pode chegar antes de local_post.created, e timestamp recebe a marca de tempo quando a entrega é preparada, não quando a alteração ocorreu. Quando a ordem for importante, consulte novamente o recurso pela API REST e confie no resultado dessa consulta. profile.updated também é limitado a um evento por local a cada 60 segundos, então uma nova consulta na chegada do evento mostra apenas a primeira edição; consulte novamente após o encerramento desse intervalo.

Direcione o processamento com base em event e encaminhe cada evento ao código que você escreveu para o guia correspondente. O webhook informa quando; o endpoint desse guia informa o quê.

EventoO que aconteceuGuia para consultar
profile.createdUm local foi criado. data.source é user, api ou bulk_edit.Adicionar um local
profile.updatedUm local foi editado. data.changed_fields é uma indicação, não um diff completo.Atualizar um local
profile.deletedUm local foi arquivado.Encontrar e arquivar locais
listing.submissionUm envio a uma plataforma de publicação foi concluído: status é success, incomplete ou canceled.Verificar o status das listagens e os links publicados
connection.location_connected, connection.location_disconnectedUma listagem do Google ou do Facebook foi vinculada ou desvinculada.Conectar o Google e importar locais, Conectar Páginas do Facebook
connection.reauth_required, connection.listing_inaccessibleAs credenciais expiraram ou o acesso a uma listagem foi perdido.Conectar o Google e importar locais
connection.google_verification_verified, connection.google_verification_failedUma verificação do Google foi concluída.Criar um Google Business Profile
interaction.reviewUma avaliação chegou ou foi alterada. Os campos ficam dentro de data.interaction.Buscar avaliações
interaction.responseUma resposta do proprietário foi publicada, editada ou arquivada. parent_id é a avaliação.Responder a avaliações
local_post.created, local_post.published, local_post.rejected, local_post.deletedUm item de publicação mudou de estado. Use data.post_item_id como chave, um por local.Publicar posts
review_analytics.daily_snapshot, review_analytics.weekly_snapshotA agregação das avaliações foi executada para um local e um período.Análise de avaliações

As tabelas de campos e um exemplo de payload para cada evento estão na referência de eventos.

7. Recupere-se de um evento perdido com consultas periódicas

Como não há novas tentativas, cada família precisa de uma consulta a uma fonte confiável à qual você possa recorrer. Faça a reconciliação em intervalos programados, não apenas quando algo parecer errado, e nunca grave zero ou "sem alteração" porque um evento não chegou.

FamíliaConsulte novamente comIntervalo
listing.submissionGET /locations/{locationId}/listings/premium para syncStatus e listingUrl.Diariamente, por local.
profile.*GET /locations ou GET /locations/search; updatedAt mostra o que mudou.A cada hora e também 60 segundos após cada profile.updated.
connection.*GET /connected-accounts e googleVerificationStatus em qualquer consulta de local.A cada hora; a cada poucos minutos durante uma verificação.
interaction.*GET /rollup_interactions em toda a conta, GET /reviewDetails por id.A cada 15 minutos para novas avaliações, diariamente para uma varredura.
local_post.*GET /posts/{postId} ou GET /locations/{locationId}/posts.A cada hora enquanto os posts estiverem em processamento.
review_analytics.*GET /locations/{locationId}/review-analytics-overview com as datas do período.Diariamente, após a agregação.

O painel registra cada tentativa com um status. success indica que a entrega foi realizada; um código HTTP, timeout, network_error ou error significa que seu endpoint foi alcançado e falhou; blocked_url, endpoint_unverified e rate_limited significam que nenhuma requisição foi feita, portanto a correção está na configuração ou no plano, não no código. A lista completa está em Comportamento de entrega.

Próximos passos