# Webhooks

Multilistado notifies your CRM or website as soon as a lead arrives or a listing changes status, with a signed JSON POST.

Source: https://multilistado.mx/developers/webhooks?lang=en · Updated: 2026-09-30 · Other language: https://multilistado.mx/developers/webhooks.md

## Set up

1. Go to [Portal → More → Webhooks](https://multilistado.mx/portal/webhooks).
2. Enter your receiver **URL**: it must be `https://` on a public server (private IPs and `localhost` are refused).
3. Tick the **Events** you want and click **Create**.
4. Copy the **secret** shown ("Webhook created. Secret (save it): …"): **it is shown only once** and is used to verify the signature.
5. Click **Test** to send a test `lead.created`. The **Last status** column shows `ok`, `http 4xx`/`http 5xx` or `error: delivery failed` with the time.

Webhooks are per agent: you receive events for your listings and for leads assigned to you.

Create a free API key at https://multilistado.mx/portal/api-keys (verified agents).

## Events

| Event | When it is sent | `data` |
|---|---|---|
| `lead.created` | When a new lead is notified to you: forms on multilistado.mx, your Multilistado site, the [widget](https://multilistado.mx/developers/widget?lang=en) and collections. **Showing requests** are sent once the buyer finishes verifying their ID | `id`, `name`, `email`, `phone`, `message`, `kind` (`info`/`tour`), `id_status`, `listing` {`id`, `slug`, `title`}, `source` |
| `lead.updated` | When you change a lead's stage in the portal | `id`, `status` |
| `listing.published` | When a listing is published (portal or [API](https://multilistado.mx/developers/api?lang=en#publicar)) or reconfirmed | `id`, `slug`, `title`, `status` |
| `listing.unpublished` | When it is unpublished, withdrawn, marked sold/rented or hidden by the system (e.g. not reconfirmed) | `id`, `slug`, `status` (sometimes `title`) |
| `listing.updated` | The agent edited a published listing in the portal (not sent for edits made via the API) | `id`, `slug`, `title`, `status` |

No webhooks are sent for leads **you** create with `POST /leads` or for your API `PUT`s (this avoids loops with CRMs that sync both ways).

## Payload

```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":"Is it still available?","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=` + the hex HMAC-SHA256 of the **exact body** (raw bytes) keyed with your secret. Verify it **before** parsing the JSON and compare in constant time.

## Verify the signature

```javascript
// Node.js 18+, no dependencies. MULTILISTADO_WEBHOOK_SECRET = the secret from the 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);                       // raw bytes: do not use 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);                     // store it in your CRM (in the background)
    res.writeHead(200).end('ok');                            // answer fast (under 10 s)
  });
}).listen(process.env.PORT || 3000);
```

```php
<?php
// webhook.php — PHP 7.4+. MULTILISTADO_WEBHOOK_SECRET = the secret from the portal.
$secret = getenv('MULTILISTADO_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');                     // raw body
$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'] ?? ''));   // store it in your CRM
http_response_code(200);
echo 'ok';
```

```python
# Flask (pip install flask). MULTILISTADO_WEBHOOK_SECRET = the secret from the 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()                                 # raw bytes
    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"))               # store it in your CRM
    return "ok", 200
```

## Delivery and retries

- One attempt per event, as soon as it happens. **There are no automatic retries**: if your server is down, check **Last status** in the portal and recover what you missed with `GET /api/v1/leads` or `GET /api/v1/my/properties` ([API](https://multilistado.mx/developers/api?lang=en)).
- Any **2xx** response counts as delivered. Timeout: **10 seconds**. Redirects are not followed (use the final URL). Only the first 64 KB of your response are read.
- `https://` to public addresses only; the URL is checked when created and on every delivery.
- Duplicates can happen (e.g. publish, unpublish, publish again): identify each event by `event` + `data.id` + `created_at` and process idempotently.
- The signature has no timestamp: drop events whose `created_at` is too old if replay concerns you.
