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:
La cabecera de firma
Cada entrega firmada incluye:
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
- 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.
- Calcula
HMAC-SHA256(your_secret, raw_body), codifica el resultado en base64 y añade el prefijosha256=. - 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. - Si no coinciden, devuelve
401y no proceses nada.
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 contrato del desafío
El desafío es una solicitud firmada normal, con un nombre de evento reservado:
{ "event": "endpoint.verification", "nonce": "9f2c1e642b1a4b0e9f1a2c7d5e8a1b3c", "timestamp": "2026-08-25T14:32:10Z"}Tu endpoint debe:
- Verificar
X-ListingsAPI-Signaturecomo lo haría para cualquier entrega. - Reconocer
event == "endpoint.verification"y seguir una ruta de ejecución distinta antes del procesamiento normal de eventos. - 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. - Responder
200con 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.
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}, 200Vectores 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):
whsec_test_0123456789abcdef0123456789abcdefCuerpo de la solicitud sin procesar, exactamente estos 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"}}Firma esperada de la solicitud, en base64, con el prefijo sha256=:
X-ListingsAPI-Signature: sha256=+FxQAwiCAfQdicZcHlv9fNOrkjf0yK99Jje97VMS6Y4=Para el desafío de verificación, con el mismo secreto y este nonce:
9f2c1e642b1a4b0e9f1a2c7d5e8a1b3ctu endpoint debe responder 200 con exactamente este cuerpo hexadecimal en minúsculas:
d5aff73f9739b49aaabccf264c8777a697998ba07c493fbaec646b5370354145Para 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:
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
- Comportamiento de entrega: el cuerpo de datos estándar, el límite de 5 segundos y todos los estados de entrega.
- Referencia de eventos: los 18 eventos y sus cuerpos de datos.