Webhooks
A webhook endpoint is an https URL of yours that letsrealty calls whenever something happens to your Leads: a new Lead, an edit, a status move, a new Inquiry, a completed Activity. Calls usually arrive within a minute of the change.
Events
lead.created— a Lead was created, by any path.lead.updated— contact or pipeline fields changed;data.changed_fieldslists which.lead.status_changed— a new status;data.previous_statushas the old one.lead.assigned— a new Lead Owner;data.previous_assigned_agent_idhas the old one.lead.scored— AI scored the Lead.lead.archived— the Lead was archived.inquiry.created— a Lead got in touch;data.leadcarries the Lead.activity.completed— an Activity was marked done.ping— a test sent from the console or the API; never subscribed to.
Every event has the same envelope; data is the full resource, never a diff, so you never need a follow-up call:
{
"id": "2f6d1c4e-…",
"type": "lead.status_changed",
"version": 1,
"created_at": "2026-09-30T14:05:00.123456+00:00",
"tenant": { "slug": "my-agency" },
"data": { "id": "6f1c…", "name": "Ana Rojas", "status": "contacted", "previous_status": "new", "…": "…" }
}
The API reference documents every payload field under Webhooks.
Registering an endpoint
A Tenant Admin can add one under Integrations → Webhooks in the console, choosing its events. The console shows the signing secret (whsec_…) once.
An integration can do the same with a webhooks:manage key — the REST-hook pattern Zapier uses:
curl https://api.letsrealty.io/v1/webhooks/subscriptions \
-H "Authorization: Bearer $LETSREALTY_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/letsrealty", "events": ["lead.created", "inquiry.created"] }'
The 201 answer carries the endpoint's id and its secret; store both. DELETE /v1/webhooks/subscriptions/{endpoint_id} unsubscribes, and only works on endpoints the same key subscribed. Revoking a key turns off the endpoints it subscribed. URLs must be https and reach the public internet.
Verifying a delivery
Every call is signed following Standard Webhooks. It carries three headers:
webhook-id— the eventid; the same on every retry and redelivery, so use it to ignore duplicates.webhook-timestamp— when this attempt was sent, in Unix seconds.webhook-signature—v1,followed by the base64 HMAC-SHA256 of{webhook-id}.{webhook-timestamp}.{raw body}, keyed with the base64-decoded part of your secret afterwhsec_.
Always verify against the raw request body, before parsing it, and reject timestamps more than five minutes away from your clock.
Python
With the standardwebhooks package (pip install standardwebhooks):
from standardwebhooks.webhooks import Webhook
webhook = Webhook(LETSREALTY_WEBHOOK_SECRET) # "whsec_…"
def handle(raw_body: bytes, headers: dict[str, str]) -> None:
event = webhook.verify(raw_body, headers) # raises if the signature is wrong
...
Or with the standard library only:
import base64
import hashlib
import hmac
import time
def verify(secret: str, headers: dict[str, str], raw_body: bytes) -> None:
msg_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("timestamp too far from now")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for candidate in headers["webhook-signature"].split():
version, _, signature = candidate.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return
raise ValueError("bad signature")
Node.js
With the standardwebhooks package (npm install standardwebhooks), in Express:
import express from "express";
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.LETSREALTY_WEBHOOK_SECRET); // "whsec_…"
const app = express();
app.post("/hooks/letsrealty", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body.toString("utf8"), req.headers);
} catch {
return res.sendStatus(400);
}
res.sendStatus(204);
// handle `event` after answering
});
Or with node:crypto only:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error("timestamp too far from now");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
const ok = headers["webhook-signature"].split(" ").some((candidate) => {
const [version, signature] = candidate.split(",");
const given = Buffer.from(signature ?? "", "base64");
return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
});
if (!ok) throw new Error("bad signature");
}
Answering, retries and redelivery
Answer with any 2xx within 10 seconds; do slow work afterwards. Anything else — an error, a timeout, a redirect — is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours and 24 hours: about three days in all. Events can arrive out of order; compare created_at or the resource's own fields when it matters.
After 20 failed attempts in a row the endpoint is turned off and your Tenant Admins are emailed. Answering 410 Gone turns it off at once. Turning it back on in the console (or with PATCH /v1/webhooks/{endpoint_id} and "enabled": true) resets the count.
Every attempt is in the endpoint's delivery log, in the console and at GET /v1/webhooks/{endpoint_id}/deliveries. To send one again — say, after fixing a bug — use Redeliver in the console or POST /v1/webhooks/{endpoint_id}/deliveries/{delivery_id}:redeliver. A redelivery keeps the same webhook-id. POST /v1/webhooks/{endpoint_id}/test sends a ping.
Catching up
If your endpoint was down longer than the retries, GET /v1/events lists the events of the last 30 days, oldest first, in the same envelope. Pass since — a time with a UTC offset, or the id of the last event you processed — and optionally type:
curl "https://api.letsrealty.io/v1/events?since=2f6d1c4e-…" \
-H "Authorization: Bearer $LETSREALTY_API_KEY"
A key only sees the events its read scopes cover: leads:read for lead.* and inquiry.*, activities:read for activity.*.
Rotating the secret
POST /v1/webhooks/{endpoint_id}/rotate-secret (or Rotate secret in the console) issues a new secret. The old one stops verifying immediately, so update your receiver right after.