Elite module 02
CRM Webhooks setup.
Send every Prop Nexia lead to your CRM via HMAC-signed POST. Pipedrive, HubSpot, Zoho, Bitrix24 or custom endpoint. SSRF-hardened, retry with backoff.
What CRM Webhooks does
CRM Webhooks delivers every Prop Nexia lead to your CRM as a signed HTTPS POST request. Works with any CRM that can receive a webhook — Pipedrive, HubSpot, Zoho, Bitrix24, Salesforce, or a custom endpoint your team builds.
The webhook is signed so your CRM can verify the request really came from your site. If your CRM is offline when a lead comes in, the webhook is retried automatically. No lead gets dropped.
Activate the module
Make sure your Elite licence is valid. Then:
- Go to Elite → Modules in your WordPress admin
- Find CRM Webhooks in the list
- Click Activate
A new menu appears: Elite → CRM Webhooks. This is where you create webhooks, view delivery logs, and manage secrets.
Add a webhook
- Elite → CRM Webhooks → Add Webhook
- Enter the Endpoint URL — must be HTTPS. Private IPs (10.x, 192.168.x) and loopback addresses are blocked.
- Select which events fire this webhook (see below)
- Click Save. Elite generates the HMAC secret.
- Copy the secret immediately — it’s shown only once. Paste it into your CRM’s webhook receiver settings.
Available events
You choose which events fire each webhook. Common setups: send lead.created to your sales CRM, send property.published to your reporting tool.
| Event | When it fires |
|---|---|
lead.created | A visitor submits an enquiry form on any listing or page. |
lead.updated | A lead is edited in admin — status change, note added, agent re-assigned. |
lead.assigned | A lead is assigned to a specific agent. |
property.created | A property is saved as published for the first time. |
property.published | A property status changes to published. |
property.sold | A property is marked as sold or let. |
buyer.signed_up | A buyer creates an account (Buyer Accounts module). |
search.saved | A buyer saves a search (Saved Searches module). |
One webhook URL can receive multiple events. Or set up separate webhooks for each event if you want them routed to different systems.
Payload format
Every webhook sends a JSON body and three custom headers. Here’s what a lead.created request looks like:
POST /your-endpoint HTTP/1.1
Content-Type: application/json
X-PN-Event: lead.created
X-PN-Timestamp: 1747315200
X-PN-Signature: t=1747315200,v1=a9c2b3d4e5f6...
{
"event": "lead.created",
"lead_id": "ld_8421",
"property": {
"id": 123,
"slug": "marina-view-apartment",
"title": "Marina View Apartment",
"price": 2400000,
"currency": "AED",
"city": "Dubai",
"community": "Dubai Marina"
},
"buyer": {
"name": "A. Khan",
"email": "buyer@example.com",
"phone": "+971501234567",
"message": "Is this still available?"
},
"source": "lead-form",
"agent_id": "ag_42",
"ts": "2026-05-14T10:32:00Z"
}
The body structure depends on the event type. property.* events have a property object but no buyer. buyer.signed_up has a buyer but no property. The event field at the top of the JSON tells you which type to handle.
Verifying the signature
Every webhook is signed. Your CRM should verify the signature before processing the payload. This is how it confirms the request really came from your site.
How to verify
- Read the
X-PN-TimestampandX-PN-Signatureheaders - Take the raw request body (do not parse and re-serialise it)
- Compute HMAC-SHA256 of
{timestamp}.{body}using your webhook secret - Compare your computed hash with the
v1=part of theX-PN-Signatureheader
Reject the request if
- The computed signature doesn’t match the
v1=value - The timestamp is older than 5 minutes (replay protection)
- The body content-type isn’t
application/json
Signature verification — example code
Node.js / Expressconst crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const secret = process.env.PN_WEBHOOK_SECRET;
const sigHeader = req.header('X-PN-Signature');
const ts = req.header('X-PN-Timestamp');
// Parse signature header: t=...,v1=...
const v1 = sigHeader.split(',').find(p => p.startsWith('v1=')).slice(3);
// Compute HMAC of timestamp.body
const payload = `${ts}.${req.body.toString()}`;
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
// Constant-time compare
if (!crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Reject if older than 5 minutes
if (Math.floor(Date.now() / 1000) - parseInt(ts) > 300) {
return res.status(401).send('Timestamp too old');
}
// Process the lead
const data = JSON.parse(req.body);
console.log('Lead received:', data.lead_id);
res.status(200).send('OK');
});
PHP
<?php
$secret = getenv('PN_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_PN_TIMESTAMP'] ?? '';
$sigHeader = $_SERVER['HTTP_X_PN_SIGNATURE'] ?? '';
// Parse v1= from signature header
preg_match('/v1=([a-f0-9]+)/', $sigHeader, $matches);
$v1 = $matches[1] ?? '';
// Compute expected signature
$expected = hash_hmac('sha256', "{$ts}.{$body}", $secret);
// Constant-time compare
if (!hash_equals($v1, $expected)) {
http_response_code(401);
exit('Invalid signature');
}
// Reject if older than 5 minutes
if (time() - (int)$ts > 300) {
http_response_code(401);
exit('Timestamp too old');
}
// Process the lead
$data = json_decode($body, true);
error_log('Lead received: ' . $data['lead_id']);
http_response_code(200);
echo 'OK';
Python / Flask
import hmac, hashlib, time, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['PN_WEBHOOK_SECRET']
@app.route('/webhook', methods=['POST'])
def webhook():
sig_header = request.headers.get('X-PN-Signature', '')
ts = request.headers.get('X-PN-Timestamp', '')
body = request.get_data(as_text=True)
v1 = next((p[3:] for p in sig_header.split(',') if p.startswith('v1=')), '')
expected = hmac.new(SECRET.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected):
abort(401, 'Invalid signature')
if int(time.time()) - int(ts) > 300:
abort(401, 'Timestamp too old')
data = request.get_json()
print('Lead received:', data['lead_id'])
return 'OK', 200
JSON.stringify in Node), the bytes will differ and the signature won’t match.
Retries
If your endpoint returns a non-2xx status, or doesn’t respond within 10 seconds, Elite retries with exponential backoff.
| Attempt | Delay before retry |
|---|---|
| 1 (initial) | — |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 6 hours |
| After 6 attempts | Marked failed. Manual retry from admin. |
The full activity log lives in Elite → CRM Webhooks → Activity. Every attempt is logged — success, failure, retry, final. You can manually retry any failed delivery from the log.
Security
- HTTPS only. Plain
http://URLs are rejected when you save the webhook. - SSRF-hardened. Private IPs (10.x, 192.168.x), link-local (169.254.x) and loopback (127.x) addresses are blocked at the network layer — not just URL validation. Your webhook cannot be tricked into hitting your own internal services.
- HMAC-SHA256 signing. Every payload is signed with your secret.
- Replay protection. The timestamp is part of the signed body. Requests older than 5 minutes are rejected by spec.
- Secret rotation. Rotate the secret any time from the admin. Your CRM will need to be reconfigured with the new secret.
Worked examples per CRM
Here’s how to wire Prop Nexia webhooks into the three most common CRMs Dubai agencies use.
Pipedrive
- Pipedrive → Settings → Tools and integrations → Webhooks isn’t the receiver here — you need a small middleware (Pipedrive doesn’t accept signed webhooks directly).
- Use a service like Make.com or Zapier as the receiver. Or deploy the Node.js example above to a small VPS.
- In the middleware, after verifying the signature, call Pipedrive’s Add Lead API endpoint with the buyer data.
- Map the Prop Nexia fields:
buyer.name→ person name,buyer.email→ email,property.title→ deal title.
HubSpot
- HubSpot → Settings → Integrations → Private Apps. Create a private app with
crm.objects.contacts.writescope. - Copy the access token.
- In your middleware, after verifying the signature, POST to
https://api.hubapi.com/crm/v3/objects/contactswith the buyer data. - Use
property_listingor a custom property to attach the listing context.
Zoho CRM
- Zoho CRM → Setup → Developer Hub → APIs and SDKs. Generate a self-client OAuth token.
- In your middleware, after verifying the signature, POST to
https://www.zohoapis.com/crm/v2/Leadswith the buyer data. - Map fields:
buyer.name→ Last_Name,buyer.email→ Email,property.title→ Lead_Source_Detail.
Troubleshooting
Webhook never reaches my CRM
Check the activity log: Elite → CRM Webhooks → Activity.
- If you see no attempts — the events aren’t configured. Edit the webhook and confirm the right events are selected.
- If you see attempts failing — read what status code your endpoint returned. 401 means signature verification failed on your side. 500 means your endpoint code crashed. 404 means the URL is wrong.
- If you see timeouts — your endpoint is taking longer than 10 seconds. Acknowledge the webhook quickly with a 200, then process asynchronously.
Signature verification fails on my CRM
The most common cause: re-encoding the body before signing. For example, in Node.js if you use app.use(express.json()) before your webhook route, Express parses the JSON and you’ve lost the raw bytes. Use express.raw({ type: 'application/json' }) on the webhook route specifically, as shown in the Node example above.
Sign the raw request body, byte for byte. Not the parsed-and-re-stringified version.
Endpoint returns 200 but lead doesn’t appear in CRM
This is a mapping issue inside your CRM, not a webhook problem. Use the activity log to see exactly what was sent. Then check:
- Your CRM’s API logs — did the API call you made to the CRM actually succeed?
- Field mapping — is the buyer email going into the right field on the CRM side?
- Required fields — does the CRM require fields that the webhook payload doesn’t include?
Webhook is firing duplicates
If you see the same lead arrive multiple times, your endpoint is probably returning a non-2xx code and Elite is retrying. Make sure your endpoint returns 200 as soon as it has accepted the payload — even if processing happens later in the background.
