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:
O cabeçalho de assinatura
Cada entrega assinada contém:
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
- 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.
- Calcule
HMAC-SHA256(your_secret, raw_body), codifique o resultado em base64 e adicione o prefixosha256=. - 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. - Se os valores não coincidirem, retorne
401e não processe nada.
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.)
O contrato do desafio
O desafio é uma requisição assinada comum, com um nome de evento reservado:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Seu endpoint deve:
- Verificar
X-ListingsAPI-Signaturecomo faria para qualquer entrega. - Reconhecer
event == "endpoint.verification"e seguir um fluxo específico antes do processamento normal de eventos. - 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. - Responder com
200e 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.
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 });});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}, 200Vetores 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):
whsec_test_0123456789abcdef0123456789abcdefCorpo bruto da requisição, exatamente estes bytes:
{"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=:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=Para o desafio de verificação, com o mesmo segredo e este nonce:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3cseu endpoint deve responder com 200 e exatamente este corpo hexadecimal em minúsculas:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Para reproduzir ambos, salve a linha do corpo acima em body.json sem quebra de linha no final (printf '%s' '...' > body.json) e, em seguida:
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
- Comportamento de entrega: o payload padrão, o limite de 5 segundos e todos os status de entrega.
- Referência de eventos: todos os 18 eventos e seus payloads.