Assinaturas e verificação

Verifique o cabeçalho X-ListingsAPI-Signature, responda ao desafio de verificação do endpoint e evite confundir base64 com hex.

Sua URL de webhooks é um endpoint HTTPS público, então qualquer pessoa na internet pode enviar uma requisição POST para ele. O segredo de assinatura permite distinguir uma entrega autêntica de uma falsificação: cada requisição assinada contém um HMAC calculado com um segredo que apenas você e a plataforma possuem.

Dois mecanismos distintos usam esse segredo, e é fácil confundi-los:

FinalidadeDireçãoCodificação
Assinatura da requisiçãoComprovar que cada entrega é autênticaEnviada para você, em um cabeçalhobase64
Desafio de verificaçãoComprovar uma única vez que você possui o segredoVocê o envia de volta, no corpohex

O cabeçalho de assinatura

Cada entrega assinada contém:

Text
X-ListingsAPI-Signature: sha256=<digest>   digest = base64( HMAC-SHA256(secret, raw_request_body) )

O prefixo sha256= faz parte do valor do cabeçalho. Inclua-o ao montar a string esperada ou compare apenas a parte após =. Nada é adicionado antes do corpo para calcular o hash: nenhum carimbo de data e hora, nenhuma versão, nenhuma string de assinatura concatenada. O HMAC abrange apenas o corpo bruto.

Verificação passo a passo

  1. Leia os bytes brutos do corpo da requisição, exatamente como foram recebidos. Não interprete o JSON para depois serializá-lo novamente. A ordem das chaves, os espaços em branco e as sequências de escape Unicode alteram os bytes, e qualquer alteração invalida o HMAC. A maioria dos frameworks precisa ser configurada para preservar o corpo bruto. Veja os exemplos abaixo.
  2. Calcule HMAC-SHA256(your_secret, raw_body), codifique o resultado em base64 e adicione o prefixo sha256=.
  3. Compare com o cabeçalho usando uma comparação em tempo constante (crypto.timingSafeEqual, hmac.compare_digest). Uma comparação simples com == revela, pelo tempo de execução, informações sobre quantos bytes iniciais coincidiram.
  4. Se os valores não coincidirem, retorne 401 e não processe nada.
A verificação no lado do receptor. Tudo depende da segunda etapa: o HMAC é calculado sobre os bytes que você recebeu, e não sobre qualquer conteúdo que você tenha recodificado.

Verificação do endpoint

Antes que os eventos reais comecem a ser enviados, seu endpoint precisa comprovar, uma única vez, que de fato possui o segredo. Isso impede que uma sequência de payloads assinados seja enviada a uma URL digitada incorretamente ou pertencente a alguém que não possa validá-la.

Você inicia a verificação na página Webhooks do painel. A plataforma envia um desafio assinado e aguarda até 10 segundos pela resposta. (Esses 10 segundos se aplicam apenas à negociação de verificação; o tempo limite das entregas reais é de 5 segundos.)

A negociação de verificação. Observe a diferença: o desafio é assinado com base64 em um cabeçalho, e sua resposta é um resumo criptográfico em hexadecimal no corpo da resposta.

O contrato do desafio

O desafio é uma requisição assinada comum, com um nome de evento reservado:

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

Seu endpoint deve:

  1. Verificar X-ListingsAPI-Signature como faria para qualquer entrega.
  2. Reconhecer event == "endpoint.verification" e seguir um fluxo específico antes do processamento normal de eventos.
  3. Calcular HMAC-SHA256(secret, nonce) como uma string hexadecimal em minúsculas. O HMAC é calculado apenas sobre o nonce, não sobre o corpo da requisição.
  4. Responder com 200 e essa string hexadecimal no corpo da resposta. Espera-se uma string hexadecimal sem delimitadores; um valor hexadecimal entre aspas ou representado como uma string JSON também é aceito.

Qualquer outro resultado (um status fora da faixa 2xx, um resumo criptográfico incorreto, um tempo limite excedido, um host inacessível) faz a verificação falhar.

O que redefine a verificação

A verificação não é permanente. A conta volta ao estado não verificado, e as entregas são pausadas, sempre que:

  • Você altera a URL dos webhooks ou gera novamente o segredo. A comprovação anterior não se aplica mais à nova configuração.
  • Uma tentativa de verificação falha: isso inclui um endpoint que funcionava e cujo nome deixou de ser resolvido ou que deixou de responder. Portanto, executar novamente a verificação de um endpoint com problemas desativará as entregas; esse comportamento é intencional.

Faça a verificação novamente quando o endpoint estiver funcionando corretamente para retomar as entregas.

Receptores funcionais

Os dois exemplos realizam as três tarefas: preservam o corpo bruto, verificam a assinatura em tempo constante e respondem ao desafio de verificação. Ambos leem um segredo do ambiente: há exatamente um segredo válido por conta e nenhum período de sobreposição, portanto não crie um laço que aceite qualquer um de dois segredos.

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

Vetores de teste

Entradas fixas e as saídas exatas produzidas por uma implementação correta. Incorpore esses vetores a um teste unitário antes de direcionar uma entrega real ao seu endpoint.

Segredo (obviamente fictício, apenas para testes):

Text
whsec_test_0123456789abcdef0123456789abcdef

Corpo bruto da requisição, exatamente estes bytes:

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

Assinatura esperada da requisição, em base64, com o prefixo sha256=:

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

Para o desafio de verificação, com o mesmo segredo e este nonce:

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

seu endpoint deve responder com 200 e exatamente este corpo hexadecimal em minúsculas:

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

Para reproduzir ambos, salve a linha do corpo acima em body.json sem quebra de linha no final (printf '%s' '...' > body.json) e, em seguida:

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

Se a primeira linha não corresponder ao valor do cabeçalho acima, os bytes estão incorretos, não o HMAC. Verifique primeiro o comprimento do corpo.

Próximo