Firmas y verificación

Verifica el encabezado X-ListingsAPI-Signature, responde al desafío de verificación del endpoint y evita confundir base64 con hex.

La URL de tus webhooks es un endpoint HTTPS público, por lo que cualquiera en internet puede enviarle una solicitud POST. El secreto de firma te permite distinguir una entrega auténtica de una falsificada: cada solicitud firmada incluye un HMAC calculado con un secreto que solo tú y la plataforma tenéis.

Dos mecanismos distintos utilizan ese secreto y es fácil confundirlos:

FinalidadDirecciónCodificación
Firma de la solicitudDemostrar que cada entrega es auténticaSe te envía a ti, en una cabecerabase64
Desafío de verificaciónDemostrar una sola vez que tienes el secretoLo envías de vuelta, en el cuerpohex

La cabecera de firma

Cada entrega firmada incluye:

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

El prefijo sha256= forma parte del valor de la cabecera. Inclúyelo al construir la cadena esperada o compara solo la parte que sigue a =. No se antepone nada al cuerpo antes de calcular el hash: ni una marca de tiempo, ni una versión, ni una cadena de firma concatenada. El HMAC abarca únicamente el cuerpo sin procesar.

Verificación paso a paso

  1. Lee los bytes del cuerpo de la solicitud sin procesar, exactamente como se recibieron. No analices el JSON ni vuelvas a serializarlo. El orden de las claves, los espacios en blanco y las secuencias de escape Unicode modifican los bytes, y cualquier cambio invalida el HMAC. En la mayoría de los frameworks, debes configurar explícitamente la conservación del cuerpo sin procesar. Consulta los ejemplos que aparecen a continuación.
  2. Calcula HMAC-SHA256(your_secret, raw_body), codifica el resultado en base64 y añade el prefijo sha256=.
  3. Compara el resultado con la cabecera mediante una comparación de tiempo constante (crypto.timingSafeEqual, hmac.compare_digest). Una simple comparación con == filtra información temporal sobre cuántos bytes iniciales coincidieron.
  4. Si no coinciden, devuelve 401 y no proceses nada.
La comprobación en el receptor. Todo depende del segundo paso: el HMAC se calcula sobre los bytes que recibiste, no sobre datos que hayas vuelto a codificar.

Verificación del endpoint

Antes de que empiecen a enviarse eventos reales, tu endpoint debe demostrar, una sola vez, que tiene el secreto. Esto evita que se envíe un flujo de cargas útiles firmadas a una URL mal escrita o que pertenezca a alguien que no pueda validarlo.

Inicias la verificación desde la página Webhooks del panel. La plataforma envía un desafío firmado y espera hasta 10 segundos a recibir la respuesta. (Esos 10 segundos se aplican solo al intercambio de verificación; las entregas reales tienen un tiempo de espera de 5 segundos.)

El intercambio de verificación. Observa la diferencia: el desafío lleva una firma en base64 en una cabecera y tu respuesta contiene un resumen criptográfico en hexadecimal en el cuerpo.

El contrato del desafío

El desafío es una solicitud firmada normal, con un nombre de evento reservado:

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

Tu endpoint debe:

  1. Verificar X-ListingsAPI-Signature como lo haría para cualquier entrega.
  2. Reconocer event == "endpoint.verification" y seguir una ruta de ejecución distinta antes del procesamiento normal de eventos.
  3. Calcular HMAC-SHA256(secret, nonce) como una cadena hexadecimal en minúsculas. El HMAC se calcula solo sobre el nonce, no sobre el cuerpo de la solicitud.
  4. Responder 200 con esa cadena hexadecimal como cuerpo de la respuesta. Se espera una cadena hexadecimal sin delimitadores; también se acepta un valor hexadecimal entre comillas o representado como una cadena JSON.

Cualquier otro resultado (un código distinto de 2xx, un resumen incorrecto, un tiempo de espera agotado o un host inaccesible) hace que la verificación falle.

Qué restablece el estado de verificación

La verificación no es permanente. La cuenta vuelve al estado sin verificar y las entregas se pausan siempre que:

  • Cambias la URL de los webhooks o regeneras el secreto. La prueba anterior ya no es válida para la nueva configuración.
  • Falla un intento de verificación: esto incluye los endpoints que antes funcionaban y cuyo nombre de host ha dejado de resolverse o que han dejado de responder. Por tanto, volver a ejecutar una verificación contra un endpoint que no funciona desactivará las entregas; es un comportamiento intencionado.

Vuelve a verificar cuando el endpoint funcione correctamente para reanudar las entregas.

Receptores funcionales

Ambos ejemplos realizan las tres tareas: conservar el cuerpo sin procesar, verificar la firma en tiempo constante y responder al desafío de verificación. Ambos leen un secreto del entorno: hay exactamente un secreto válido por cuenta y no existe un período de validez simultánea, así que no implementes un bucle que acepte cualquiera de dos secretos.

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

Vectores de prueba

Entradas fijas y las salidas exactas que produce una implementación correcta. Incorpóralas a una prueba unitaria antes de enviar una entrega real a tu endpoint.

Secreto (claramente ficticio, solo para pruebas):

Text
whsec_test_0123456789abcdef0123456789abcdef

Cuerpo de la solicitud sin procesar, exactamente estos 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"}}

Firma esperada de la solicitud, en base64, con el prefijo sha256=:

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

Para el desafío de verificación, con el mismo secreto y este nonce:

Text
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c

tu endpoint debe responder 200 con exactamente este cuerpo hexadecimal en minúsculas:

Text
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145

Para reproducir ambos resultados, guarda la línea del cuerpo anterior en body.json sin salto de línea al final (printf '%s' '...' > body.json) y, después:

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

Si la primera línea no coincide con el valor de la cabecera anterior, los bytes son incorrectos, no el HMAC. Comprueba primero la longitud del cuerpo.

Siguiente