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.
{
"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.
Generated from the same list the API uses (GET /events). Subscribe to any set, or *.
| Type | Resource in data | When |
|---|---|---|
ping | ping | Sent by "Send test" and POST /webhook-endpoints/{id}/test. data = {message}. |
property.created | property | A property was added. |
property.updated | property | A property changed. data.previous holds the fields that changed, with their old values. |
property.marketing_status.changed | property | The marketing status moved (available, under_offer, let_agreed, let, not_marketed…). |
lead.created | lead | A new enquiry (lead) arrived, from any source including POST /leads. |
lead.status.changed | lead | An enquiry moved between new, contacted, viewing_booked, converted and closed. |
viewing.created | viewing | A viewing was booked. |
viewing.updated | viewing | A viewing changed (time, attendee, notes, feedback). data.previous holds the old values. |
viewing.status.changed | viewing | A viewing was confirmed, completed, cancelled or marked no-show. |
tenancy.created | tenancy | A tenancy was created (any status). |
tenancy.started | tenancy | A tenancy became active. |
tenancy.ended | tenancy | A tenancy ended or was terminated. |
payment.received | payment | A rent payment was recorded against a tenancy. |
arrears.flagged | rent_charge | A rent charge became overdue. |
maintenance.created | maintenance_ticket | A repair ticket was raised. |
maintenance.status.changed | maintenance_ticket | A repair ticket changed status. |
maintenance.note.added | maintenance_note | A note was posted on a repair ticket. data = the note, with ticket_id. |
maintenance.completed | maintenance_ticket | A repair ticket was completed. |
document.created | document | A document was stored (upload or import). |
certificate.added | certificate | A safety certificate was recorded. |
certificate.expiring | certificate | A certificate expires within 30 days (sent once, by the daily check). |
certificate.expired | certificate | A certificate has expired (sent once, by the daily check). |
task.created | task | A task was created. |
task.completed | task | A task was marked done. |
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(" "))
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 attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| wait | 5 s | 5 min | 30 min | 2 h | 5 h | 10 h | 14 h | 20 h | 24 h |
POST /webhook-deliveries/{id}/redeliver).id to de-duplicate and timestamp to order.ping event is sent by the Send test button and POST /webhook-endpoints/{id}/test.