FlowEstate sends webhooks when leads, projects, or units change. Subscribers register a target URL and an event type; FlowEstate fans out signed POST requests with a JSON body.
To manage subscriptions programmatically (create, list, delete), see the Webhooks API reference. This page covers what your receiver needs to do once a subscription is in place.
Every delivery includes:
| Header | Description |
|---|
Content-Type | Always application/json. |
User-Agent | FlowEstate-Webhook/1.0 |
X-FlowEstate-Event | Event type, e.g. lead.created. |
X-FlowEstate-Delivery | UUID of this delivery attempt. Use it for idempotent processing. |
X-FlowEstate-Signature | sha256=<hex> HMAC-SHA256 of the exact raw body using the endpoint secret. |
X-FlowEstate-Source | Origin of the event: ui, api, or system. Use this to break loops — see below. |
Body
{
"id": "evt_<uuid>",
"type": "lead.created",
"createdAt": "2026-04-29T18:04:15.000Z",
"organization": {
"id": "org_...",
"name": "Acme Realty",
"slug": "acme-realty"
},
"data": { /* event-specific payload */ }
}
data is event-specific:
lead.* events carry data.lead.
project.* events carry data.project.
unit.* events carry data.unit and data.project.
lead.note_added carries data.leadId and data.note.
lead.communication_logged carries data.leadId and data.communication.
For the full list of events see Enums.
Verifying the signature
Always verify the signature before trusting a payload. Anyone who knows your URL can hit it; only FlowEstate has the secret.
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express
app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("X-FlowEstate-Signature"), process.env.FLOWESTATE_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// ... handle event ...
res.status(200).end();
});
Sign the raw bytes of the request body. Re-serializing the parsed JSON will produce a different signature because key order, whitespace, and number formatting may differ.
The secret comes from the response of POST /webhooks/subscriptions and is shown only once. If you lose it, delete the subscription and create a new one.
Acknowledging
Return any 2xx HTTP status to acknowledge receipt. FlowEstate doesn’t read the response body — empty 200 is fine.
If your handler does heavy work (calling other APIs, writing to a slow database), acknowledge first, process later:
app.post("/webhook", (req, res) => {
if (!verify(...)) return res.status(401).end();
res.status(200).end(); // ACK first
enqueueForBackgroundProcessing(req.body); // process out-of-band
});
The delivery timeout is a few seconds — slow handlers cause spurious retries.
Retry policy
Failed deliveries (network error, timeout, or HTTP ≥ 400) are retried with this backoff:
| Attempt | Delay before retry |
|---|
| 1 | — (initial) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 hours |
After five failed attempts the delivery is marked failed and won’t be retried again. The X-FlowEstate-Delivery header stays the same across retries — use it as your idempotency key when processing.
Preventing loops
When you write to FlowEstate via this REST API, the resulting webhook fans out with X-FlowEstate-Source: api. If your receiver also writes back into FlowEstate, drop events with source api to avoid an infinite loop.
if (req.headers["x-flowestate-source"] === "api") {
return res.status(200).end(); // ack and ignore
}
The other source values:
ui — change made by a user clicking in the FlowEstate dashboard.
system — change made by an internal worker (assignments, automations).
api — change made through this REST API (your own writes, partner platforms).