Reference · Webhooks
Webhooks
How it works
- You register an HTTPS endpoint — in the app under Org admin → Integrations → Add endpoint, or with POST /webhooks — and store the signing secret you get back.
- When someone on your team, or an AI agent, qualifies a lead in the Greenfinch pipeline, a
lead.qualifiedevent is recorded. It fires once per lead: the first time that property enters the qualified stage. - Greenfinch assembles the lead bundle and POSTs it to every active endpoint subscribed to the event, signed with that endpoint's secret.
- Your endpoint verifies the signature, stores the lead, and answers
2xx.
Subscribe
curl -X POST "https://app.greenfinch.ai/api/v1/webhooks" \
-H "Authorization: Bearer $GREENFINCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.example.com/hooks/greenfinch",
"events": [
"lead.qualified"
]
}'- The URL must be public
https://. Addresses that point at localhost, private networks or link-local ranges, or that embed a username and password, are refused — and re-checked on every delivery. - The response includes
signing_secret(whsec_…) once. Subscribing the same URL again replaces the endpoint and issues a new secret. - An organization can have 10 active endpoints. Leaving out
eventssubscribes tolead.qualifiedonly; change events (below) are subscribed to by name, once Greenfinch has switched them on for your organization.
The delivery
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
Greenfinch-Signature | t=<unix seconds>,v1=<hex signature> — see below |
Greenfinch-Event-Id | The delivery's ID; the same as event_id in the body. |
Greenfinch-Event-Type | lead.qualified, or a change event type (below) |
Body
{
"event_id": "3f2a9c1e-0000-4000-8000-000000000611",
"event_type": "lead.qualified",
"created_at": "2026-09-12T16:40:26.000Z",
"test": false,
"data": {
"bundle_version": 1,
"greenfinch_property_id": "3f2a9c1e-0000-4000-8000-000000000001",
"generated_at": "2026-09-12T16:40:26.000Z",
"delivered_to_org": "org_example000000000001",
"partial": false,
"partial_reason": null,
"property": {
"address": "1200 Commerce Pkwy",
"city": "Plano",
"state": "TX",
"zip": "75074",
"common_name": "Northgate Business Park",
"asset_category": "Office",
"deep_link_url": "https://app.greenfinch.ai/property/3f2a9c1e-0000-4000-8000-000000000001",
"…": "…"
},
"organizations": [
{
"greenfinch_organization_id": "3f2a9c1e-0000-4000-8000-000000000021",
"role": "property_manager",
"name": "Lakeside Property Services",
"…": "…"
}
],
"contacts": [
{
"greenfinch_contact_id": "3f2a9c1e-0000-4000-8000-000000000011",
"full_name": "Dana Whitfield",
"title": "Director of Facilities",
"email": "dana.whitfield@example.com",
"…": "…"
}
],
"contacts_summary": null,
"pipeline": {
"in_pipeline": true,
"status": "qualified",
"deal_value": 48000,
"…": "…"
},
"notes": [],
"notes_digest": "",
"actions": [],
"actions_digest": ""
}
}| Field | Meaning |
|---|---|
event_id | Unique per delivery to one endpoint, and the same on every retry of it. Your idempotency key. |
event_type | lead.qualified |
created_at | When this delivery attempt was signed (ISO-8601). |
test | true when the lead was qualified as a test. Always present; filter these out in production. |
data | The lead bundle. |
Partial bundles
If the property was not unlocked for your organization when it was qualified, the delivery still arrives with the fields your plan includes: data.partial is true, partial_reason is "not_revealed", contacts is empty, and contacts_summary lists the title of each contact and whether an email, phone or LinkedIn profile is available. Unlock the property and fetch GET /leads/{id} for the full record.
Change events
A change event tells you Greenfinch recorded a change on a property or a person your organization can see: a new owner, a newer sale, a different managing firm, or a person who left their employer or changed title or employer. Each change is sent once per organization, to every endpoint subscribed to its type, and never again. They are switched on per organization; until then, subscribing to one is refused.
| Event type | Sent when |
|---|---|
signal.ownership_transfer | A property changed hands (county record). |
signal.sale | A newer sale was recorded on a property. |
signal.manager_change | Research found a different managing firm. |
signal.contact_departure | A person left the employer we showed. |
signal.contact_title_change | A person's title changed. |
signal.contact_employer_change | A person's employer changed. |
signal.withdrawn | A change already sent to this endpoint was retracted. Names the original event. |
{
"event_id": "3f2a9c1e-0000-4000-8000-000000000612",
"event_type": "signal.manager_change",
"created_at": "2026-09-25T14:05:11.000Z",
"test": false,
"data": {
"payload_version": "signal.v1",
"event_id": "3f2a9c1e-0000-4000-8000-000000000701",
"kind": "manager_change",
"subject": {
"type": "property",
"id": "3f2a9c1e-0000-4000-8000-000000000001"
},
"detected_at": "2026-09-25T13:58:40.000Z",
"source": "research",
"signal_id": "3f2a9c1e-0000-4000-8000-000000000801:manager_change"
}
}data.event_idis your organization's id for the change — the same on every endpoint — and is what a latersignal.withdrawnnames indata.withdraws_event_id. The envelope'sevent_idstays the per-endpoint idempotency key.- The body names the change and carries no values: no owner names, no old or new title or employer, no date beyond
detected_at. Read the evidence for a change with greenfinch_get_changes, per property or person. - To receive the evidence in the body instead, register the endpoint with
"include_evidence": true;data.evidencethen holds the changed field and its values before and after, exactly asgreenfinch_get_changesshows them to you. It carries only what your plan and unlocks show: a manager change on a property you have not unlocked arrives with the firm names empty andmanager_withheld: true; exact sale and transfer dates need the premium plan (dates_withheld: trueotherwise); owner names are withheld where the owner asked not to be listed (owner_withheld: true). - The change is re-checked when it is sent: one you can no longer see (outside your territory, or already retracted) is not sent. Change events cost no credits; reading the property or person itself is priced as usual.
- Changes recorded before your first change subscription are not replayed. Use greenfinch_get_changes to read earlier ones.
Verify the signature
Every delivery is signed with HMAC-SHA256 using your endpoint's signing secret. The signed message is the timestamp, a period, and the raw request body:
Greenfinch-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>- Parse
tandv1from the header. Reject a missing or malformed header. - Reject the delivery if
tis more than 5 minutes from your clock — it may be a replay. - Compute the HMAC over
t + "." + raw bodywith the full secret (including thewhsec_prefix) and compare it tov1in constant time.
Use the raw body
Verify against the bytes exactly as received. Parsing the JSON and serializing it again changes spacing or key order and the signature will not match.
import crypto from "node:crypto";
import express from "express";
const SIGNING_SECRET = process.env.GREENFINCH_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 5 * 60;
/** Throws unless the delivery was signed by Greenfinch in the last 5 minutes. */
function verifyGreenfinchSignature(rawBody, header, secret = SIGNING_SECRET) {
const match = /^t=(\d+),v1=([0-9a-f]+)$/i.exec(header ?? "");
if (!match) throw new Error("Missing or malformed Greenfinch-Signature header");
const [, timestamp, signature] = match;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > TOLERANCE_SECONDS) throw new Error("Signature timestamp outside tolerance");
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Signature does not match");
}
}
const app = express();
const seen = new Set(); // use your database in production
// Keep the body as raw bytes: the signature covers them exactly as sent.
app.post("/hooks/greenfinch", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8");
try {
verifyGreenfinchSignature(rawBody, req.get("Greenfinch-Signature"));
} catch {
return res.status(401).end();
}
const event = JSON.parse(rawBody);
if (seen.has(event.event_id)) return res.status(200).end(); // a retry
seen.add(event.event_id);
if (!event.test) {
upsertLead(event.data); // key your record on event.data.greenfinch_property_id
}
res.status(200).end();
});Respond, retries and failures
| Your endpoint answers | What Greenfinch does |
|---|---|
Any 2xx within 10 seconds | Delivered. The response body is ignored. |
410 Gone | Stops immediately and disables the endpoint — the standard way to unsubscribe from the receiving side. |
| Any other status, a timeout, or a redirect | Retries. Redirects are never followed. |
- Retries. Up to 5 attempts in total, waiting 30 seconds, then 1, 2 and 4 minutes between them. Each retry is signed again with a fresh timestamp and carries the same
event_id. - Automatic disabling. After 20 consecutive failed deliveries the endpoint is disabled and your admins are notified. An endpoint whose address resolves to a private network is disabled at once. Fix it and re-enable it on the Integrations page.
- Daily export limit. If your organization has set a daily lead-export limit and it is reached, the next attempt is postponed until just after the limit resets at midnight UTC, rather than retried straight away.
- Billing. Each delivered bundle is a lead export: free within your monthly allowance, then 1 credit. See lead bundle billing.
Idempotency and ordering
- Deliveries are at-least-once. Store
event_idand ignore a delivery you have already processed. - Deliveries are not guaranteed to arrive in order. Use
data.generated_atto keep the newest version of a lead, and key your CRM record ondata.greenfinch_property_id, which never changes. - Missed deliveries while your endpoint was down can be recovered by polling GET /leads and fetching each bundle.
Unsubscribe
curl -X DELETE "https://app.greenfinch.ai/api/v1/webhooks/3f2a9c1e-0000-4000-8000-000000000501" \
-H "Authorization: Bearer $GREENFINCH_API_KEY"Or answer a delivery with 410, or remove the endpoint on the Integrations page.