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:

  1. Go to Elite → Modules in your WordPress admin
  2. Find CRM Webhooks in the list
  3. Click Activate

A new menu appears: Elite → CRM Webhooks. This is where you create webhooks, view delivery logs, and manage secrets.

Add a webhook

  1. Elite → CRM Webhooks → Add Webhook
  2. Enter the Endpoint URL — must be HTTPS. Private IPs (10.x, 192.168.x) and loopback addresses are blocked.
  3. Select which events fire this webhook (see below)
  4. Click Save. Elite generates the HMAC secret.
  5. Copy the secret immediately — it’s shown only once. Paste it into your CRM’s webhook receiver settings.
Save the secret somewhere safe. After you close the screen, the secret is hashed in the database and we can’t show it again. If you lose it, you have to rotate the secret and update your CRM with the new one.

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.

EventWhen it fires
lead.createdA visitor submits an enquiry form on any listing or page.
lead.updatedA lead is edited in admin — status change, note added, agent re-assigned.
lead.assignedA lead is assigned to a specific agent.
property.createdA property is saved as published for the first time.
property.publishedA property status changes to published.
property.soldA property is marked as sold or let.
buyer.signed_upA buyer creates an account (Buyer Accounts module).
search.savedA 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:

HTTP request
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

  1. Read the X-PN-Timestamp and X-PN-Signature headers
  2. Take the raw request body (do not parse and re-serialise it)
  3. Compute HMAC-SHA256 of {timestamp}.{body} using your webhook secret
  4. Compare your computed hash with the v1= part of the X-PN-Signature header

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 / Express
const 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
Critical. Sign the raw request body — byte for byte, exactly as received. If you parse the JSON and then re-serialise it (for example with 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.

AttemptDelay before retry
1 (initial)
230 seconds
32 minutes
410 minutes
51 hour
66 hours
After 6 attemptsMarked 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

  1. Pipedrive → Settings → Tools and integrations → Webhooks isn’t the receiver here — you need a small middleware (Pipedrive doesn’t accept signed webhooks directly).
  2. Use a service like Make.com or Zapier as the receiver. Or deploy the Node.js example above to a small VPS.
  3. In the middleware, after verifying the signature, call Pipedrive’s Add Lead API endpoint with the buyer data.
  4. Map the Prop Nexia fields: buyer.name → person name, buyer.email → email, property.title → deal title.

HubSpot

  1. HubSpot → Settings → Integrations → Private Apps. Create a private app with crm.objects.contacts.write scope.
  2. Copy the access token.
  3. In your middleware, after verifying the signature, POST to https://api.hubapi.com/crm/v3/objects/contacts with the buyer data.
  4. Use property_listing or a custom property to attach the listing context.

Zoho CRM

  1. Zoho CRM → Setup → Developer Hub → APIs and SDKs. Generate a self-client OAuth token.
  2. In your middleware, after verifying the signature, POST to https://www.zohoapis.com/crm/v2/Leads with the buyer data.
  3. Map fields: buyer.name → Last_Name, buyer.email → Email, property.title → Lead_Source_Detail.
Full worked examples with copy-paste scripts for each CRM are in the Elite admin under Elite → CRM Webhooks → Help. They include the field mappings, the OAuth setup, and a tested middleware template you can deploy in 10 minutes.

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.