Webhooks and Events

Instead of polling every minute to see if something happened: register a URL and get notified.

Register an Endpoint

curl -X POST https://api.realestateagency.example/v1/webhooks \
  -H "apikey: $REAL_ESTATE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://crm.your-company.com/hooks/real-estate",
        "events": ["listing.completed", "inquiry.received", "appointment.confirmed"],
        "description": "Production CRM integration"
      }'

The response contains a signing_secret. It's only shown once and is used to verify incoming deliveries.

Available Events

EventTriggered by
property.createdA new property was saved
property.updatedMaster data, price, or media changed
property.archivedProperty was taken off the market
listing.completedGeneration finished, PDF is ready
listing.publishedListing is live on at least one channel
inquiry.receivedA lead reached out about a property
inquiry.scoredMatching scored an inquiry
appointment.confirmedA viewing appointment was confirmed
appointment.cancelledAn appointment was cancelled by either side
settlement.createdA commission settlement was generated

Anatomy of a Delivery

{
  "id": "evt_0d41c8",
  "type": "listing.completed",
  "created_at": "2026-08-20T09:41:20Z",
  "data": {
    "listing_id": "lst_4d9b2e",
    "property_id": "prop_8f2c1a",
    "status": "completed",
    "pdf_url": "https://api.realestateagency.example/v1/listings/lst_4d9b2e/pdf"
  }
}

Headers on every delivery:

HeaderContent
X-Real-Estate-EventEvent type, e.g. listing.completed
X-Real-Estate-DeliveryUnique ID of this delivery
X-Real-Estate-Signaturet=<unix_time>,v1=<hex>

Verifying the Signature

The signed payload is "<unix_time>.<raw_body>", via HMAC-SHA256 with your signing_secret.

import hashlib, hmac, time

def is_signature_valid(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp, signature = parts["t"], parts["v1"]

    if abs(time.time() - int(timestamp)) > tolerance:
        return False  # too old - protects against replay

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

Verify against the raw request body. Parsing to JSON and re-serializing first changes whitespace and key order - the signature then no longer matches.

Retries and Idempotency

We expect a 2xx status within five seconds. If none arrives, we retry with increasing backoff: after 1 min, 5 min, 30 min, 2 h and 6 h. After that, the delivery is considered failed, and the endpoint is automatically paused after 24 hours without success.

Deliveries can arrive more than once. Keep the processed id values around for at least seven days and discard repeats. Respond immediately with 202 and keep working asynchronously - slow processing in the request handler is the most common cause of unnecessary retries.