Book a demo Start free trial
Developers

Webhooks

We POST a signed JSON event to your https endpoint when something changes. One shape for every event, signatures you can verify in five lines, retries for a day.

The envelope

{
  "id": "evt_7h2k9x…",               // unique per event; the same id on every retry
  "type": "lead.created",
  "timestamp": "2026-11-11T10:00:00Z",
  "api_version": "v1",
  "agency_id": "9f3c…",
  "data": { "...the resource as GET returns it...",
            "previous": { "status": "new" } }   // only on *.updated / *.changed: the fields that changed, with their OLD values
}

data is the same representation the API returns for a GET, so one parser does both. Personal data in it follows the endpoint's agency, not a key: it is unmasked, because the agency chose to send its own data to its own endpoint.

Event catalogue

Generated from the same list the API uses (GET /events). Subscribe to any set, or *.

TypeResource in dataWhen
pingpingSent by "Send test" and POST /webhook-endpoints/{id}/test. data = {message}.
property.createdpropertyA property was added.
property.updatedpropertyA property changed. data.previous holds the fields that changed, with their old values.
property.marketing_status.changedpropertyThe marketing status moved (available, under_offer, let_agreed, let, not_marketed…).
lead.createdleadA new enquiry (lead) arrived, from any source including POST /leads.
lead.status.changedleadAn enquiry moved between new, contacted, viewing_booked, converted and closed.
viewing.createdviewingA viewing was booked.
viewing.updatedviewingA viewing changed (time, attendee, notes, feedback). data.previous holds the old values.
viewing.status.changedviewingA viewing was confirmed, completed, cancelled or marked no-show.
tenancy.createdtenancyA tenancy was created (any status).
tenancy.startedtenancyA tenancy became active.
tenancy.endedtenancyA tenancy ended or was terminated.
payment.receivedpaymentA rent payment was recorded against a tenancy.
arrears.flaggedrent_chargeA rent charge became overdue.
maintenance.createdmaintenance_ticketA repair ticket was raised.
maintenance.status.changedmaintenance_ticketA repair ticket changed status.
maintenance.note.addedmaintenance_noteA note was posted on a repair ticket. data = the note, with ticket_id.
maintenance.completedmaintenance_ticketA repair ticket was completed.
document.createddocumentA document was stored (upload or import).
certificate.addedcertificateA safety certificate was recorded.
certificate.expiringcertificateA certificate expires within 30 days (sent once, by the daily check).
certificate.expiredcertificateA certificate has expired (sent once, by the daily check).
task.createdtaskA task was created.
task.completedtaskA task was marked done.

Verifying the signature

We follow the Standard Webhooks convention. Three headers come with every delivery:

webhook-id:        evt_7h2k9x…
webhook-timestamp: 1762860000                      # unix seconds
webhook-signature: v1,K5oZfzN95Z9UVu1EsPQmSH+Bmqd3QATMG6N…   # base64 HMAC-SHA256

The signed content is "{webhook-id}.{webhook-timestamp}.{raw body}", the key is the secret's bytes (the whsec_ prefix stripped, then base64-decoded). Reject a timestamp more than five minutes from now. Compare in constant time. The header may hold several space-separated signatures during a secret rotation; accept if any matches.

function verifyLetfoldWebhook(string $secret, array $headers, string $rawBody): bool
{
    $id = $headers['webhook-id'] ?? ''; $ts = $headers['webhook-timestamp'] ?? ''; $sig = $headers['webhook-signature'] ?? '';
    if (! ctype_digit($ts) || abs(time() - (int) $ts) > 300) return false;
    $key = base64_decode(substr($secret, strlen('whsec_')));
    $expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$ts.$rawBody", $key, true));
    foreach (explode(' ', $sig) as $candidate) {
        if (hash_equals($expected, $candidate)) return true;
    }
    return false;
}

// Express/Laravel-style: read the RAW body, not a parsed array.
if (! verifyLetfoldWebhook(getenv('LETFOLD_WEBHOOK_SECRET'), getallheaders(), file_get_contents('php://input'))) {
    http_response_code(401); exit;
}
$event = json_decode(file_get_contents('php://input'), true);
http_response_code(200);   // answer fast; do the work after
import crypto from 'node:crypto';

export function verifyLetfoldWebhook(secret, headers, rawBody) {
  const id = headers['webhook-id'], ts = headers['webhook-timestamp'], sig = headers['webhook-signature'] || '';
  if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const expected = 'v1,' + crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64');
  return sig.split(' ').some(c => c.length === expected.length && crypto.timingSafeEqual(Buffer.from(c), Buffer.from(expected)));
}

// express: app.post('/webhooks/letfold', express.raw({ type: '*/*' }), (req, res) => { ... req.body is a Buffer ... })
import base64, hmac, hashlib, time

def verify_letfold_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    wid, ts, sig = headers.get("webhook-id", ""), headers.get("webhook-timestamp", ""), headers.get("webhook-signature", "")
    if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = "v1," + base64.b64encode(hmac.new(key, f"{wid}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(expected, c) for c in sig.split(" "))

Delivery and retries

  • Endpoints must be https on a public host (port 443 or 8443). We never follow redirects. Payloads are at most 20 KB. We wait 15 seconds for an answer.
  • 2xx = delivered. 410 Gone = you have removed the endpoint; we disable it. Any other 4xx except 408 and 429 is not retried: the request will not get better.
  • 408, 429, 5xx and network errors retry on this schedule (±10 % jitter), 10 attempts in all:
After attempt123456789
wait5 s5 min30 min2 h5 h10 h14 h20 h24 h
  • Seven days of continuous failure disables the endpoint and emails the agency owner. Failed deliveries can be re-sent from the deliveries log (or POST /webhook-deliveries/{id}/redeliver).
  • Deliveries may arrive more than once (a retry after a timeout) and occasionally out of order. Use id to de-duplicate and timestamp to order.
  • A ping event is sent by the Send test button and POST /webhook-endpoints/{id}/test.