Webhooks salientes de LotesEnVivoAlpha
Guía técnica para equipos que ya usan un CRM, ERP, automatización o sistema propio y quieren recibir en tiempo real lo que pasa en LotesEnVivo. Si tu equipo vende solo desde el mapa y el backoffice, no necesitas configurar webhooks.
Los webhooks salientes requieren Plan Premium o Empresarial. En Plan Básico la sección sigue disponible en el backoffice, pero no se pueden crear conexiones ni se envía ningún evento hasta subir de plan.
Qué es y para qué sirve
Un webhook es un aviso automático: cada vez que pasa algo importante en tu mapa (se aparta un lote, se cancela un apartado, se convierte en venta o se marca un lote como vendido), LotesEnVivo hace una petición HTTP a la URL de tu CRM, ERP o automatización, con los datos de ese evento. Así otro sistema se entera al instante para disparar seguimientos, actualizar contactos o alimentar reportes sin captura doble.
Guía rápida
Cómo empezar
- Entra a Backoffice → Integraciones → Webhooks y crea una conexión: dale un nombre, pega la URL que te dé tu sistema externo (debe empezar con
https://) y elige qué eventos quieres recibir. - Al crear la conexión se genera una clave secreta (empieza con
whsec_). Cópiala en ese momento — no se vuelve a mostrar completa después. Guárdala en el sistema que recibirá los avisos, idealmente como variable de entorno en tu servidor, nunca en el código fuente. - Usa el botón "Enviar evento de prueba" de la conexión para mandar un payload de ejemplo y confirmar que tu endpoint responde correctamente antes de depender de tráfico real.
- Implementa la verificación de firma (ver más abajo) antes de procesar cualquier evento en producción.
Eventos disponibles
Hay 6 eventos disponibles para automatizar procesos fuera de LotesEnVivo. Cada uno incluye el payload real de ejemplo tal como lo recibirá tu sistema externo.
Los payloads de reservation.*, payment.recorded y waitlist.joined incluyen datos personales de tus clientes finales (nombre, teléfono, correo) y, en algunos casos, montos. Trátalos como datos sensibles en tu sistema: guárdalos solo donde ya manejas ese tipo de información.
| Evento | Cuándo se dispara |
|---|---|
reservation.createdReserva creada | Se apartó un lote. Útil para crear o actualizar el contacto y la oportunidad en tu CRM o sistema comercial en cuanto entra un apartado. |
reservation.cancelledReserva cancelada | Se canceló un apartado, ya sea manualmente o porque venció. Úsalo para regresar la oportunidad a un estado "disponible" en tu sistema comercial. |
reservation.convertedReserva convertida en venta | El apartado se convirtió en venta. Úsalo para mover la oportunidad a "ganada" y registrar el monto. |
lot.soldLote vendido | Un lote pasó a estatus vendido. Puede llegar con o sin una reserva previa asociada; en una venta directa, esos dos campos llegan en null. |
payment.recordedPago registrado | Se registró un abono o pago sobre una cuenta de cobranza de una reserva. El evento más útil para disparar facturación, comisiones o cobranza en tu sistema externo. |
waitlist.joinedProspecto agregado a lista de espera | Un prospecto se registró en la lista de espera de un lote que ya está apartado. Es un lead calificado: quiere ese lote específico y no lo pudo tener — ideal para dar seguimiento inmediato o notificar si el lote se libera. |
reservation.createdReserva creada{
"id": "9c6f1e2a-6b8b-4b8a-9b1a-1a2b3c4d5e6f",
"type": "reservation.created",
"version": 1,
"created_at": "2026-09-05T18:32:10.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": {
"reservation_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"map_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"map_name": "Residencial Las Palmas",
"feature_id": "f9e8d7c6-b5a4-4938-8271-6b5c4d3e2f1a",
"external_id": "AC28",
"lot_code": "A-14",
"customer_name": "Karla Méndez",
"customer_phone": "+52 55 1234 5678",
"customer_email": "karla.mendez@example.com",
"reserved_at": "2026-09-05T18:32:00.000Z",
"expires_at": "2026-09-08T18:32:00.000Z",
"lot_status": "reserved",
"area_m2": 120,
"block_code": "M3",
"lot_number": "14",
"map_public_id": "kX92mQ1pR7A",
"map_url": "https://lotesenvivo.mx/m/kX92mQ1pR7A",
"assigned_seller": { "name": "Miguel Torres", "email": "miguel.torres@example.com" },
"currency": "MXN"
}
}external_id es la clave con la que tu propio sistema (ERP/CRM) identifica este lote, si la configuraste en LotesEnVivo. Viaja siempre en todo evento que incluya feature_id, incluso cuando el lote no tiene clave externa: en ese caso llega como null (nunca se omite el campo), para que puedas distinguir "sin clave" de "campo ausente". lot_status/area_m2/block_code/lot_number describen el lote al momento del evento. map_public_id/map_url te permiten linkear directo al mapa público sin construir la URL tú mismo. assigned_seller es null si el lote no tiene vendedor asignado. currency siempre es "MXN" hoy — explícito para no depender del sufijo _mxn en los nombres de campo.
reservation.cancelledReserva cancelada{
"id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"type": "reservation.cancelled",
"version": 1,
"created_at": "2026-09-05T20:10:00.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": {
"reservation_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"map_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"map_name": "Residencial Las Palmas",
"feature_id": "f9e8d7c6-b5a4-4938-8271-6b5c4d3e2f1a",
"external_id": "AC28",
"lot_code": "A-14",
"customer_name": "Karla Méndez",
"customer_phone": "+52 55 1234 5678",
"customer_email": "karla.mendez@example.com",
"cancelled_at": "2026-09-05T20:10:00.000Z",
"reason": "expired",
"lot_status": "available",
"area_m2": 120,
"block_code": "M3",
"lot_number": "14",
"map_public_id": "kX92mQ1pR7A",
"map_url": "https://lotesenvivo.mx/m/kX92mQ1pR7A",
"assigned_seller": { "name": "Miguel Torres", "email": "miguel.torres@example.com" },
"currency": "MXN"
}
}lot_status refleja el estatus del lote al momento de la cancelación (normalmente vuelve a "available" salvo que otra reserva activa lo mantenga "reserved"). Mismo criterio de map_public_id/map_url/assigned_seller/currency que en reservation.created.
reservation.convertedReserva convertida en venta{
"id": "7a8b9c0d-1e2f-4a3b-8c4d-5e6f7a8b9c0d",
"type": "reservation.converted",
"version": 1,
"created_at": "2026-09-06T15:05:00.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": {
"reservation_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"map_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"map_name": "Residencial Las Palmas",
"feature_id": "f9e8d7c6-b5a4-4938-8271-6b5c4d3e2f1a",
"external_id": "AC28",
"lot_code": "A-14",
"customer_name": "Karla Méndez",
"customer_phone": "+52 55 1234 5678",
"customer_email": "karla.mendez@example.com",
"converted_at": "2026-09-06T15:05:00.000Z",
"price_mxn": 850000,
"lot_status": "sold",
"area_m2": 120,
"block_code": "M3",
"lot_number": "14",
"map_public_id": "kX92mQ1pR7A",
"map_url": "https://lotesenvivo.mx/m/kX92mQ1pR7A",
"assigned_seller": { "name": "Miguel Torres", "email": "miguel.torres@example.com" },
"currency": "MXN"
}
}lot_status siempre llega como "sold" en este evento: la conversión de una reserva cambia el lote a vendido. Mismo criterio de map_public_id/map_url/assigned_seller/currency que en reservation.created.
lot.soldLote vendido{
"id": "3b4c5d6e-7f8a-4b9c-8d0e-1f2a3b4c5d6e",
"type": "lot.sold",
"version": 1,
"created_at": "2026-09-06T15:05:01.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": {
"map_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"map_name": "Residencial Las Palmas",
"feature_id": "f9e8d7c6-b5a4-4938-8271-6b5c4d3e2f1a",
"external_id": null,
"lot_code": "A-14",
"lot_label": "Lote A-14",
"price_mxn": 850000,
"reservation_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"customer_name": "Karla Méndez",
"customer_phone": "+52 55 1234 5678",
"customer_email": "karla.mendez@example.com",
"sold_at": "2026-09-06T15:05:01.000Z",
"lot_status": "sold",
"area_m2": 120,
"block_code": "M3",
"lot_number": "14",
"map_public_id": "kX92mQ1pR7A",
"map_url": "https://lotesenvivo.mx/m/kX92mQ1pR7A",
"assigned_seller": { "name": "Miguel Torres", "email": "miguel.torres@example.com" },
"currency": "MXN"
}
}Un mismo lote puede emitir lot.sold más de una vez. Si el estatus se corrige (se marca vendido por error, se revierte a disponible y luego se vende de verdad), se generan dos eventos lot.sold distintos, cada uno con su propio id — la deduplicación por id (ver "Reintentos y deduplicación") no cubre este caso porque son eventos genuinamente distintos, no reintentos del mismo. Decide en tu sistema externo si cada lot.sold se trata como una venta nueva o si antes consultas el estado actual del lote. external_id siempre viene presente en el payload: es null cuando el lote no tiene clave externa configurada en LotesEnVivo, nunca se omite el campo. customer_phone/customer_email son null cuando la venta no tiene una reservación asociada (venta directa). assigned_seller es null si el lote no tiene vendedor asignado.
payment.recordedPago registrado{
"id": "6d7e8f9a-0b1c-4d2e-9f3a-4b5c6d7e8f9a",
"type": "payment.recorded",
"version": 1,
"created_at": "2026-09-05T18:00:00.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": {
"payment_id": "8a9b0c1d-2e3f-4a5b-8c6d-7e8f9a0b1c2d",
"payment_account_id": "9b0c1d2e-3f4a-4b5c-8d6e-7f8a9b0c1d2e",
"reservation_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"map_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"map_name": "Residencial Los Encinos",
"feature_id": "f9e8d7c6-b5a4-4938-8271-6b5c4d3e2f1a",
"external_id": "AC12",
"lot_code": "A-12",
"customer_name": "Juana Pérez",
"customer_phone": "+52 55 1234 5678",
"customer_email": "juana.perez@example.com",
"amount_mxn": 15000,
"concept": "mensualidad",
"method": "transferencia",
"paid_at": "2026-09-05",
"receipt_folio": "REC-000123",
"lot_status": "reserved",
"area_m2": 120,
"block_code": "M3",
"lot_number": "12",
"map_public_id": "kX92mQ1pR7A",
"map_url": "https://lotesenvivo.mx/m/kX92mQ1pR7A",
"assigned_seller": { "name": "Miguel Torres", "email": "miguel.torres@example.com" },
"currency": "MXN"
}
}concept es uno de estos valores: enganche, mensualidad, abono, adelanto, escritura, otro. Úsalos para mapear el tipo de cobro en tu sistema externo. Por privacidad, el evento deliberadamente NO incluye datos bancarios (cuenta, tarjeta, CLABE) ni el campo libre de referencia/notas del pago, que puede haberse capturado a mano por el vendedor. amount_mxn/price_mxn siguen sin renombrarse — currency: "MXN" es informativo, no reemplaza esos nombres de campo.
waitlist.joinedProspecto agregado a lista de espera{
"id": "7e8f9a0b-1c2d-4e3f-9a4b-5c6d7e8f9a0b",
"type": "waitlist.joined",
"version": 1,
"created_at": "2026-09-05T18:00:00.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": {
"waitlist_entry_id": "0b1c2d3e-4f5a-4b6c-8d7e-8f9a0b1c2d3e",
"reservation_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a",
"map_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"map_name": "Residencial Los Encinos",
"feature_id": "f9e8d7c6-b5a4-4938-8271-6b5c4d3e2f1a",
"external_id": "AC12",
"lot_code": "A-12",
"customer_name": "Carlos Ramírez",
"customer_phone": "+52 55 8765 4321",
"customer_email": "carlos.ramirez@example.com",
"priority": 1,
"joined_at": "2026-09-05T18:00:00.000Z",
"lot_status": "reserved",
"area_m2": 120,
"block_code": "M3",
"lot_number": "12",
"map_public_id": "kX92mQ1pR7A",
"map_url": "https://lotesenvivo.mx/m/kX92mQ1pR7A",
"assigned_seller": { "name": "Miguel Torres", "email": "miguel.torres@example.com" },
"currency": "MXN"
}
}reservation_id corresponde al lote ya apartado por el que se hizo fila, no a una reserva del prospecto en lista de espera. priority es la posición del prospecto en la lista (1 = siguiente en la fila si el lote se libera). lot_status casi siempre es "reserved" (por eso existe la fila de espera).
Estructura del sobre
Todo evento llega envuelto en la misma estructura, sin importar el tipo. Los 4 campos de nivel raíz nunca cambian de forma — solo data varía según type.
{
"id": "9c6f1e2a-6b8b-4b8a-9b1a-1a2b3c4d5e6f",
"type": "reservation.created",
"version": 1,
"created_at": "2026-09-05T18:32:10.000Z",
"workspace_id": "b1a2c3d4-e5f6-4a1b-8c2d-9e0f1a2b3c4d",
"data": "varía según el tipo de evento, ver la sección de eventos"
}| Campo | Descripción |
|---|---|
id | Identificador único del evento. Se mantiene igual entre reintentos — es tu llave de deduplicación. |
type | Uno de los 6 tipos de evento, ej. lot.sold. |
version | Versión del formato del payload (actualmente 1). |
created_at | Fecha y hora del evento en ISO 8601 (UTC). |
workspace_id | Identificador de la desarrolladora (workspace) en LotesEnVivo. |
data | Los datos específicos del evento — su forma exacta está documentada arriba, por tipo. |
La parte más importante
Cómo verificar la firma
Nunca proceses un webhook sin verificar su firma: cualquiera que conozca tu URL podría enviarte peticiones falsas (por ejemplo, marcando ventas que no existen).
| Header | Contenido |
|---|---|
X-LotesEnVivo-Signature | t=<unix_seconds>,v1=<hex_hmac_sha256> |
X-LotesEnVivo-Event-Id | ID del evento. Se mantiene igual entre reintentos — úsalo para deduplicar. |
X-LotesEnVivo-Event-Type | El mismo valor que type dentro del cuerpo, ej. reservation.created. |
Algoritmo
La firma es un HMAC-SHA256, en hexadecimal, calculado así:
HMAC_SHA256(secret, "${t}.${raw_request_body}")tes el timestamp Unix (segundos) que viene en el header.raw_request_bodyes el cuerpo exacto de la petición, tal cual llegó por HTTP — antes de hacerJSON.parse. Si tu framework ya parseó el body automáticamente, necesitas capturar el raw body por separado.- Verifica que el timestamp no difiera del reloj de tu servidor por más de una tolerancia razonable (recomendado: 5 minutos / 300 segundos) para prevenir ataques de replay con una firma capturada previamente.
- Compara la firma calculada contra la recibida en tiempo constante (
timingSafeEqualen Node,hash_equalsen PHP) para no filtrar información por temporización.
Node.js
const crypto = require("crypto");
// secret: la clave whsec_... de tu conexión (Backoffice > Webhooks > Generar nueva clave)
// header: el valor completo del header X-LotesEnVivo-Signature ("t=...,v1=...")
// rawBody: el cuerpo de la petición SIN parsear (string tal cual llegó, antes de JSON.parse)
function verifyLotesEnVivoSignature(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(
header.split(",").map((part) => part.trim().split("="))
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) {
throw new Error("Encabezado de firma con formato inválido.");
}
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > toleranceSeconds) {
throw new Error("La firma expiró (posible replay).");
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const receivedBuffer = Buffer.from(signature, "hex");
const valid =
expectedBuffer.length === receivedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
if (!valid) throw new Error("La firma no coincide.");
return true;
}
// Ejemplo de uso en un endpoint Express. IMPORTANTE: usa el body crudo (raw), no el JSON ya
// parseado — la firma se calcula sobre el string exacto que enviamos.
// alreadyProcessed, handleWebhookEvent y markProcessed son de ejemplo: reemplázalas por tu
// propia lógica (tu base de datos, tu CRM, etc.) — ver el endpoint completo en la sección
// "Receptor completo, listo para pegar".
app.post(
"/webhooks/lotesenvivo",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
verifyLotesEnVivoSignature(
process.env.LOTESENVIVO_WEBHOOK_SECRET,
req.header("X-LotesEnVivo-Signature"),
req.body.toString("utf8")
);
} catch (error) {
return res.status(401).json({ error: error.message });
}
const eventId = req.header("X-LotesEnVivo-Event-Id");
const eventType = req.header("X-LotesEnVivo-Event-Type");
const envelope = JSON.parse(req.body.toString("utf8"));
// Deduplica por eventId antes de procesar: un mismo evento puede llegar más de una vez
// si hubo reintentos (ver sección "Reintentos y deduplicación").
if (await alreadyProcessed(eventId)) {
return res.status(200).send("ok");
}
await handleWebhookEvent(eventType, envelope.data);
await markProcessed(eventId);
res.status(200).send("ok");
}
);PHP
<?php
// secret: la clave whsec_... de tu conexión (Backoffice > Webhooks > Generar nueva clave)
// $header: el valor completo del header X-LotesEnVivo-Signature ("t=...,v1=...")
// $rawBody: el cuerpo de la petición sin parsear (file_get_contents("php://input"))
function verify_lotesenvivo_signature(
string $secret,
string $header,
string $rawBody,
int $toleranceSeconds = 300
): bool {
$parts = [];
foreach (explode(",", $header) as $pair) {
[$key, $value] = array_map("trim", explode("=", $pair, 2));
$parts[$key] = $value;
}
$timestamp = isset($parts["t"]) ? (int) $parts["t"] : 0;
$signature = $parts["v1"] ?? "";
if (!$timestamp || !$signature) {
throw new Exception("Encabezado de firma con formato inválido.");
}
$age = abs(time() - $timestamp);
if ($age > $toleranceSeconds) {
throw new Exception("La firma expiró (posible replay).");
}
$expected = hash_hmac("sha256", "{$timestamp}.{$rawBody}", $secret);
// hash_equals ya compara en tiempo constante.
if (!hash_equals($expected, $signature)) {
throw new Exception("La firma no coincide.");
}
return true;
}
// Ejemplo de uso en un endpoint plano (webhook.php).
// already_processed, handle_webhook_event y mark_processed son de ejemplo: reemplázalas por tu
// propia lógica (tu base de datos, tu CRM, etc.) — ver el endpoint completo en la sección
// "Receptor completo, listo para pegar".
$rawBody = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_LOTESENVIVO_SIGNATURE"] ?? "";
$secret = getenv("LOTESENVIVO_WEBHOOK_SECRET");
try {
verify_lotesenvivo_signature($secret, $header, $rawBody);
} catch (Exception $e) {
http_response_code(401);
echo json_encode(["error" => $e->getMessage()]);
exit;
}
$eventId = $_SERVER["HTTP_X_LOTESENVIVO_EVENT_ID"] ?? "";
$eventType = $_SERVER["HTTP_X_LOTESENVIVO_EVENT_TYPE"] ?? "";
$envelope = json_decode($rawBody, true);
// Deduplica por $eventId antes de procesar: un mismo evento puede llegar más de una vez
// si hubo reintentos (ver sección "Reintentos y deduplicación").
if (already_processed($eventId)) {
http_response_code(200);
echo "ok";
exit;
}
handle_webhook_event($eventType, $envelope["data"]);
mark_processed($eventId);
http_response_code(200);
echo "ok";De la teoría a un endpoint que corre
Receptor completo, listo para pegar
Los snippets anteriores muestran el algoritmo de verificación. Aquí está integrado dentro de un endpoint completo: recibe la petición, lee el body crudo, verifica la firma, responde y procesa el evento. Es una base para tu equipo técnico; ajústala al sistema donde guardas clientes, seguimientos o reportes.
El error más común al conectar Express: usar express.json() en la ruta del webhook. Ese middleware parsea el body a un objeto y descarta el string original — sin el string exacto, la firma nunca va a coincidir aunque tu clave secreta sea correcta. Usa express.raw({ type: "application/json" }) en su lugar, como en el ejemplo de abajo.
Estos ejemplos ya integran la verificación de firma de la sección anterior en un endpoint completo: reciben el POST, leen el body crudo, verifican la firma, responden y luego procesan el evento. Cópialos tal cual y ajusta solo handleWebhookEvent / handle_webhook_event a tu lógica real.
Node.js (Express)
const express = require("express");
const crypto = require("crypto");
const app = express();
// CRÍTICO: NO uses app.use(express.json()) en esta ruta. express.json() parsea el body y
// descarta el string original — sin el raw body exacto, la firma nunca va a coincidir.
// express.raw() te da el Buffer tal cual llegó por HTTP, que es lo que necesitas.
app.post(
"/webhooks/lotesenvivo",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
const header = req.header("X-LotesEnVivo-Signature");
try {
verifyLotesEnVivoSignature(process.env.LOTESENVIVO_WEBHOOK_SECRET, header, rawBody);
} catch (error) {
// Firma inválida: rechaza. Esto NO cuenta como entrega fallida para efectos de reintento
// "normal" del lado de LotesEnVivo, pero tampoco proceses el evento.
return res.status(401).json({ error: error.message });
}
// Responde 200 YA, antes de hacer cualquier trabajo pesado. Si tu lógica (llamadas a tu CRM,
// a una base de datos, etc.) tarda más de un par de segundos, hazla en background después de
// responder — no antes. Ver sección "Qué debe responder tu endpoint".
res.status(200).send("ok");
const eventId = req.header("X-LotesEnVivo-Event-Id");
const eventType = req.header("X-LotesEnVivo-Event-Type");
const envelope = JSON.parse(rawBody);
if (await alreadyProcessed(eventId)) return; // Deduplica: ver sección de reintentos.
await handleWebhookEvent(eventType, envelope.data);
await markProcessed(eventId);
}
);
app.listen(3000);
// La misma función de la sección "Cómo verificar la firma".
function verifyLotesEnVivoSignature(secret, header, rawBody, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.trim().split("=")));
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) throw new Error("Encabezado de firma con formato inválido.");
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > toleranceSeconds) throw new Error("La firma expiró (posible replay).");
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const expectedBuffer = Buffer.from(expected, "hex");
const receivedBuffer = Buffer.from(signature, "hex");
const valid =
expectedBuffer.length === receivedBuffer.length &&
crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
if (!valid) throw new Error("La firma no coincide.");
return true;
}PHP
<?php
// webhook.php — endpoint completo, listo para colocar detrás de tu servidor HTTPS.
// PHP nunca "parsea" el body por ti antes de que tu código corra, así que aquí no hay
// equivalente al error de express.json(): file_get_contents("php://input") siempre te da el
// raw body exacto. Aun así, evita llamar a json_decode() antes de verificar la firma.
$rawBody = file_get_contents("php://input");
$header = $_SERVER["HTTP_X_LOTESENVIVO_SIGNATURE"] ?? "";
$secret = getenv("LOTESENVIVO_WEBHOOK_SECRET");
try {
verify_lotesenvivo_signature($secret, $header, $rawBody);
} catch (Exception $e) {
http_response_code(401);
echo json_encode(["error" => $e->getMessage()]);
exit;
}
// Responde 200 YA. Si tu procesamiento es lento, encólalo (una tabla de "jobs", una cola real,
// etc.) y procésalo aparte — no hagas esperar la respuesta HTTP a que termine.
http_response_code(200);
echo "ok";
// A partir de aquí ya respondiste; lo que sigue puede tardar sin arriesgar un timeout.
$eventId = $_SERVER["HTTP_X_LOTESENVIVO_EVENT_ID"] ?? "";
$eventType = $_SERVER["HTTP_X_LOTESENVIVO_EVENT_TYPE"] ?? "";
$envelope = json_decode($rawBody, true);
if (already_processed($eventId)) {
exit; // Deduplica: ver sección de reintentos.
}
handle_webhook_event($eventType, $envelope["data"]);
mark_processed($eventId);
// La misma función de la sección "Cómo verificar la firma".
function verify_lotesenvivo_signature(
string $secret,
string $header,
string $rawBody,
int $toleranceSeconds = 300
): bool {
$parts = [];
foreach (explode(",", $header) as $pair) {
[$key, $value] = array_map("trim", explode("=", $pair, 2));
$parts[$key] = $value;
}
$timestamp = isset($parts["t"]) ? (int) $parts["t"] : 0;
$signature = $parts["v1"] ?? "";
if (!$timestamp || !$signature) {
throw new Exception("Encabezado de firma con formato inválido.");
}
$age = abs(time() - $timestamp);
if ($age > $toleranceSeconds) {
throw new Exception("La firma expiró (posible replay).");
}
$expected = hash_hmac("sha256", "{$timestamp}.{$rawBody}", $secret);
if (!hash_equals($expected, $signature)) {
throw new Exception("La firma no coincide.");
}
return true;
}Crítico para no duplicar ventas
Reintentos y deduplicación
Si tu endpoint no responde con un código 2xx (o no responde a tiempo), reintentamos la entrega con backoff exponencial, hasta 6 intentos en total:
| Intento | Espera desde el intento anterior |
|---|---|
| 2 | 30 segundos |
| 3 | 2 minutos |
| 4 | 10 minutos |
| 5 | 30 minutos |
| 6 (último) | 2 horas |
El campo id del evento (y el header X-LotesEnVivo-Event-Id) se mantiene igual entre reintentos. Tu endpoint debe guardar los IDs ya procesados y descartar (con un 200, sin reprocesar) cualquier evento repetido. Sin esto, un reintento normal podría registrar la misma venta dos veces en tu sistema.
Esta deduplicación por id no cubre el caso de lot.sold emitido más de una vez para el mismo lote (por ejemplo, tras corregir un estatus marcado por error): cada corrección genera un evento lot.sold distinto, con su propio id. Ver el detalle en la sección "Eventos disponibles".
Requisitos del endpoint receptor
- Debe ser HTTPS — no aceptamos endpoints en
http://. - Debe responder con un código 2xx rápido (idealmente en menos de un par de segundos): confirma la recepción primero y procesa el evento después, en vez de hacer todo el trabajo pesado antes de responder. Si tu endpoint no responde a tiempo o responde algo distinto de 2xx, se cuenta como fallo y se reintenta (ver "Reintentos y deduplicación" arriba) — nada se pierde, pero sí se retrasa.
- No se aceptan IPs privadas ni localhost como destino (ej.
127.0.0.1,192.168.x.x,10.x.x.x) — el endpoint debe ser público y accesible desde internet, incluso mientras estás probando en desarrollo (ver siguiente sección).
Antes de tener un servidor definitivo
Cómo probar en local sin desplegar
No puedes registrar http://localhost:3000 ni una IP de tu red local como URL de destino: la validación de seguridad del servidor (anti-SSRF) las rechaza siempre, sin excepciones para pruebas. Necesitas una URL pública HTTPS antes de poder configurar la conexión, incluso en desarrollo.
La forma más rápida es abrir un túnel desde tu máquina hacia una URL pública temporal, sin desplegar nada todavía:
Opción 1: ngrok
# Instala ngrok (https://ngrok.com/download) e inicia sesión una vez con tu authtoken.
# Con tu servidor local corriendo en el puerto 3000:
ngrok http 3000
# ngrok imprime una URL pública HTTPS, algo como:
# https://a1b2-201-150-XX-XX.ngrok-free.app -> http://localhost:3000
# Usa esa URL (agregando tu ruta, ej. /webhooks/lotesenvivo) al crear la conexión en el backoffice.Opción 2: Cloudflare Tunnel
# Alternativa sin cuenta: Cloudflare Tunnel (cloudflared).
cloudflared tunnel --url http://localhost:3000
# Imprime una URL pública HTTPS tipo https://algo-al-azar.trycloudflare.com
# que reenvía a tu servidor local — úsala igual que la de ngrok.Con la URL del túnel ya configurada en tu conexión, usa el botón "Enviar evento de prueba" del backoffice para mandar un payload real sin esperar a que ocurra un evento de verdad. El túnel solo dura mientras tu proceso local siga corriendo — cuando despliegues tu endpoint definitivo, edita la conexión y cambia la URL por la definitiva.
Para el equipo de Kanlúm y clientes similares
Nota sobre Go High Level
Go High Level acepta webhooks entrantes estándar (Inbound Webhook / trigger de tipo "Webhook recibido" dentro de un Workflow). Puedes apuntar la URL de tu webhook de LotesEnVivo directo a esa URL de Go High Level — no necesitas Zapier ni Make de por medio. La verificación de firma se implementa en el mismo Workflow (o en un endpoint intermedio propio, si prefieres validar antes de que el payload entre a Go High Level).
¿Ya tienes tu conexión configurada?
Entra al backoffice para crear la conexión, copiar la clave y enviar un evento de prueba.