Estalo POSTs a small signed JSON message to your HTTPS endpoint when one of these happens in the office:
| Event | When |
|---|---|
lead.created | a new inquiry was saved |
lead.status_changed | an inquiry's status changed (same value saved again doesn't count) |
client.created | a new client was saved |
deal.stage_changed | a sale moved to another stage |
task.completed | a task changed to done |
event.created | a calendar event was saved |
lead.updated | anything about an inquiry changed, status included (a status change sends both this and lead.status_changed) |
client.updated | anything about a client changed |
deal.created | a new sale was saved |
task.created | a new task was saved |
property.created | a new listing was saved |
property.updated | anything about a listing changed, including moving it to the trash |
An *.updated event fires only when a stored value changed, not when a record is saved again unchanged. Delivery is at least once: deduplicate on the event id, and fetch the record through the API for its current state (data.resource can be lead, client, deal, task, event or property).
The owner adds endpoints under Settings › API and webhooks and picks the events per endpoint. Up to 10 endpoints per office.
The message
{
"id": "6f1c2b9e-0000-4000-8000-000000000000",
"type": "lead.created",
"schema_version": 1,
"sequence": 18342,
"created_at": "2026-10-09T10:15:02.114+00:00",
"organization_id": "00000000-0000-4000-8000-000000000000",
"data": { "resource": "lead", "id": "a5d0…", "version": "2026-10-09T10:15:02.114+00:00" }
}
IDs only. No names, phone numbers or amounts travel in webhooks; fetch the current record with your API key (GET /api/v1/leads/{id}). That call is also where visibility is enforced, so a record the key can't see returns 404.
data.id is a number for clients and calendar events and a UUID string for everything else. sequence grows across Estalo; it has gaps and is only good for ordering events you've already received. data.version is the record's updated_at at the moment of the change.
Events are written in the same database transaction as the change itself. If the change is rolled back, there's no event; if it's committed, the event exists.
Headers
| Header | |
|---|---|
X-Estalo-Event-Id | same as id in the body; deduplicate on this |
X-Estalo-Event-Type | same as type |
X-Estalo-Delivery-Id | one per (event, endpoint); stable across retries |
X-Estalo-Delivery-Attempt | 1, 2, … |
X-Estalo-Timestamp | Unix seconds when this attempt was signed |
X-Estalo-Signature | v1=<hex HMAC-SHA256> |
Content-Type | application/json |
User-Agent | Estalo-Webhooks/1 |
Verifying the signature
The signature is HMAC-SHA256 with your endpoint's secret over the string <X-Estalo-Timestamp>.<raw request body>. Use the raw bytes you received: parsing and re-serialising the JSON changes them and the check fails. Reject timestamps more than five minutes away from your clock, compare in constant time, and accept the request if any v1= value in the header matches (there may be more than one in future secret-rotation schemes).
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyEstalo(secret, rawBody, timestamp, signatureHeader) {
if (!/^\d+$/.test(timestamp ?? "")) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = Buffer.from("v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex"));
return (signatureHeader ?? "").split(",").some((part) => {
const candidate = Buffer.from(part.trim());
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}
Python:
import hashlib, hmac, time
def verify_estalo(secret: str, raw_body: bytes, timestamp: str, signature_header: str) -> bool:
if not timestamp or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(part.strip(), "v1=" + mac) for part in (signature_header or "").split(","))
The secret starts with whsec_. Estalo shows it once, when you add the endpoint or click "New secret"; store it in your secret manager. After a new secret is generated, messages are signed only with the new one.
Delivery: at least once
Answer with any 2xx within 10 seconds and do the real work afterwards. The 10 seconds cover the connection, the TLS handshake and your response together; the response body is ignored. Anything else is a failed attempt:
3xx: redirects are not followed;410 Gone: the delivery stops immediately, no retries;- other
4xx/5xx, timeouts and connection or TLS errors: retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h. Every retry carries the sameX-Estalo-Delivery-Idand a higherX-Estalo-Delivery-Attempt. After the eighth failed attempt the delivery is marked failed and shows up in the delivery log, where the owner can send it again.
Because a delivery can be retried after your server already processed it (for example, your 200 got lost on the way back), the same event can arrive more than once. Store X-Estalo-Event-Id and ignore repeats. Ordering isn't guaranteed either; use sequence or data.version if order matters to you.
Ten deliveries in a row that end as failed (retries exhausted, or a 410) switch the endpoint off ("Switched off after repeated failures"); any successful delivery in between resets the count, and the owner can switch the endpoint back on in settings. Events older than 72 hours that were never attempted (for example while webhooks were disabled on the server) are not sent late.
Events and their deliveries are kept for 30 days, then deleted. The delivery log covers that window, and a failed delivery can be sent again only while it is still there.
What stops a send
Each attempt re-checks, at send time, that:
- the endpoint is still active (removing it cancels everything queued for it);
- its creator is still an active member of the office with an active account;
- the office has an active plan;
- the record is visible to the creator: owners, managers and directors see the whole office, other roles only records assigned to them.
A message that fails any of these is not sent and appears as "Not sent" in the log. If the office is taken out of the API beta, queued messages are marked failed instead.
Endpoint requirements
https://with a public DNS name and the default port 443. IP addresses,localhost, single-label names and internal suffixes (.local,.internal,.lan,.home.arpaand similar) are refused.- Every address the name resolves to must be public. Private, loopback, link-local, carrier-grade NAT, cloud metadata and IPv6 unique-local/link-local ranges are refused, as are IPv6 forms that embed an IPv4 address.
- Estalo resolves the name, checks all answers and connects to the checked address without a second lookup, so a DNS answer that changes between the check and the connection can't redirect the request inside a private network.
- No credentials in the URL. Put any token you need in the path or query string, or rely on the signature.
Adding an endpoint does not send a test message to it. To try your receiver, create a lead in a test office and watch the delivery log.
Testing your receiver locally
Estalo never posts to localhost or a private network, so a receiver on your machine cannot be registered as an endpoint. Test the signature check offline instead, then register a public HTTPS address (your staging host, or a tunnel with a public DNS name) and watch the delivery log.
Take any message your endpoint received, or build one from the example under "The message" (one line, exactly the bytes received), a timestamp and the secret. The snippets below sign with a placeholder secret and then run the verifier against the good signature, a changed body, a wrong secret and a stale timestamp. Replace the placeholder with the secret Estalo showed you; never paste a real secret into a ticket or a shared document.
Node.js (uses verifyEstalo from above):
const secret = "whsec_REPLACE_WITH_YOUR_SECRET";
const rawBody = '{"id":"6f1c2b9e-0000-4000-8000-000000000000","type":"lead.created","schema_version":1,"sequence":18342,"created_at":"2026-10-09T10:15:02.114+00:00","organization_id":"00000000-0000-4000-8000-000000000000","data":{"resource":"lead","id":"a5d0f0c4-3b1e-4f2a-9c8d-0e1f2a3b4c5d","version":"2026-10-09T10:15:02.114+00:00"}}';
const timestamp = String(Math.floor(Date.now() / 1000));
const header = "v1=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
console.log(verifyEstalo(secret, rawBody, timestamp, header)); // true
console.log(verifyEstalo(secret, rawBody + " ", timestamp, header)); // false: body changed
console.log(verifyEstalo("whsec_OTHER", rawBody, timestamp, header)); // false: wrong secret
console.log(verifyEstalo(secret, rawBody, "1700000000", header)); // false: stale timestamp
Python (uses verify_estalo from above):
import time
secret = "whsec_REPLACE_WITH_YOUR_SECRET"
raw_body = b'{"id":"6f1c2b9e-0000-4000-8000-000000000000","type":"lead.created","schema_version":1,"sequence":18342,"created_at":"2026-10-09T10:15:02.114+00:00","organization_id":"00000000-0000-4000-8000-000000000000","data":{"resource":"lead","id":"a5d0f0c4-3b1e-4f2a-9c8d-0e1f2a3b4c5d","version":"2026-10-09T10:15:02.114+00:00"}}'
timestamp = str(int(time.time()))
header = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
print(verify_estalo(secret, raw_body, timestamp, header)) # True
print(verify_estalo(secret, raw_body + b" ", timestamp, header)) # False: body changed
print(verify_estalo("whsec_OTHER", raw_body, timestamp, header)) # False: wrong secret
Your receiver should also keep the X-Estalo-Event-Id values it has processed and answer 2xx to a repeat without doing the work again. A repeat looks exactly like the first delivery, only with a higher X-Estalo-Delivery-Attempt.