# Webhooks

Multilistado avisa a tu CRM o a tu sitio en cuanto llega un lead o cambia el estado de una propiedad, con un POST JSON firmado.

Fuente: https://multilistado.mx/developers/webhooks · Actualizado: 2026-09-30 · Otro idioma: https://multilistado.mx/developers/webhooks.md?lang=en

## Configurar

1. Entra a [Portal → Más → Webhooks](https://multilistado.mx/portal/webhooks).
2. Escribe la **URL** de tu receptor: debe ser `https://` y de un servidor público (no se aceptan IPs privadas ni `localhost`).
3. Marca los **Eventos** que quieres recibir y pulsa **Crear**.
4. Copia el **secreto** que aparece («Webhook creado. Secreto (guárdalo): …»): **se muestra una sola vez** y sirve para verificar la firma.
5. Pulsa **Probar** para enviar un `lead.created` de prueba. La columna **Último estado** muestra `ok`, `http 4xx`/`http 5xx` o `error: delivery failed` con la hora.

Los webhooks son por agente: recibes los eventos de tus propiedades y de los leads asignados a ti.

Crea tu API key gratis en https://multilistado.mx/portal/api-keys (agentes verificados).

## Eventos

| Evento | Cuándo se envía | `data` |
|---|---|---|
| `lead.created` | Al notificarte un lead nuevo: formularios de multilistado.mx, de tu sitio de Multilistado, del [widget](https://multilistado.mx/developers/widget) y colecciones. Las **solicitudes de visita** se envían cuando el comprador termina de verificar su identificación | `id`, `name`, `email`, `phone`, `message`, `kind` (`info`/`tour`), `id_status`, `listing` {`id`, `slug`, `title`}, `source` |
| `lead.updated` | Cuando cambias la etapa de un lead en el portal | `id`, `status` |
| `listing.published` | Al publicar una propiedad (portal o [API](https://multilistado.mx/developers/api#publicar)) o reconfirmarla | `id`, `slug`, `title`, `status` |
| `listing.unpublished` | Al despublicarla, retirarla, marcarla vendida/rentada o cuando el sistema la oculta (p. ej. sin reconfirmar) | `id`, `slug`, `status` (a veces `title`) |
| `listing.updated` | El agente editó una propiedad publicada en el portal (no se envía por ediciones hechas vía API) | `id`, `slug`, `title`, `status` |

No se envían webhooks por los leads que **tú** creas con `POST /leads` ni por tus `PUT` de la API (evita bucles con CRMs que sincronizan en ambos sentidos).

## Formato

```http
POST /hooks/multilistado HTTP/1.1
Content-Type: application/json
X-PBM-Event: lead.created
X-PBM-Signature: sha256=5d41402abc4b2a76b9719d911017c592…

{"event":"lead.created","created_at":"2026-09-30T18:04:11.482Z","data":{"id":"6f1c…","name":"Ana López","email":"ana@example.com","phone":"+52 664 123 4567","message":"¿Sigue disponible?","kind":"info","id_status":null,"listing":{"id":"07e2…","slug":"casa-en-venta-playas-de-tijuana","title":"Casa en venta en Playas de Tijuana"},"source":"widget:3f9a0c1e2b4d5a6c7e8f9012"}}
```

`X-PBM-Signature` = `sha256=` + HMAC-SHA256 en hexadecimal del **cuerpo exacto** (los bytes crudos) con tu secreto como clave. Verifícala **antes** de interpretar el JSON y compárala en tiempo constante.

## Verificar la firma

```javascript
// Node.js 18+ sin dependencias. MULTILISTADO_WEBHOOK_SECRET = el secreto del portal.
import http from 'node:http';
import crypto from 'node:crypto';

const SECRET = process.env.MULTILISTADO_WEBHOOK_SECRET;

function validSignature(rawBody, header) {
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
  const a = Buffer.from(String(header || '')), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

http.createServer((req, res) => {
  const chunks = [];
  req.on('data', c => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks);                       // bytes crudos: no uses JSON.stringify(req.body)
    if (!validSignature(raw, req.headers['x-pbm-signature'])) { res.writeHead(401).end('bad signature'); return; }
    const evt = JSON.parse(raw.toString('utf8'));
    console.log(evt.event, evt.data.id);                     // guarda en tu CRM (en segundo plano)
    res.writeHead(200).end('ok');                            // responde rápido (menos de 10 s)
  });
}).listen(process.env.PORT || 3000);
```

```php
<?php
// webhook.php — PHP 7.4+. MULTILISTADO_WEBHOOK_SECRET = el secreto del portal.
$secret = getenv('MULTILISTADO_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');                     // cuerpo crudo
$header = $_SERVER['HTTP_X_PBM_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);

if (!hash_equals($expected, $header)) {
    http_response_code(401);
    exit('bad signature');
}
$evt = json_decode($raw, true);
error_log($evt['event'] . ' ' . ($evt['data']['id'] ?? ''));   // guarda en tu CRM
http_response_code(200);
echo 'ok';
```

```python
# Flask (pip install flask). MULTILISTADO_WEBHOOK_SECRET = el secreto del portal.
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["MULTILISTADO_WEBHOOK_SECRET"].encode()

@app.post("/hooks/multilistado")
def multilistado_hook():
    raw = request.get_data()                                 # bytes crudos
    expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-PBM-Signature", "")):
        abort(401)
    evt = request.get_json()
    print(evt["event"], evt["data"].get("id"))               # guarda en tu CRM
    return "ok", 200
```

## Entrega y reintentos

- Un solo intento por evento, en cuanto ocurre. **No hay reintentos automáticos**: si tu servidor no responde, revisa **Último estado** en el portal y recupera lo perdido con `GET /api/v1/leads` o `GET /api/v1/my/properties` ([API](https://multilistado.mx/developers/api)).
- Cualquier respuesta **2xx** cuenta como entregado. Tiempo máximo: **10 segundos**. No se siguen redirecciones (usa la URL final). Solo se leen los primeros 64 KB de tu respuesta.
- Solo `https://` hacia direcciones públicas; la URL se revisa al crearla y en cada envío.
- Puede haber duplicados (p. ej. si publicas, despublicas y vuelves a publicar): identifica cada evento por `event` + `data.id` + `created_at` y procesa de forma idempotente.
- La firma no incluye marca de tiempo: descarta eventos con `created_at` muy antiguo si te preocupa la repetición.
