Developers · Webhooks

Reference · Webhooks

Webhooks

Greenfinch can push each qualified lead to your system the moment it is qualified, as a signed HTTPS POST carrying the full lead bundle.

How it works

  1. 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.
  2. When someone on your team, or an AI agent, qualifies a lead in the Greenfinch pipeline, a lead.qualified event is recorded. It fires once per lead: the first time that property enters the qualified stage.
  3. Greenfinch assembles the lead bundle and POSTs it to every active endpoint subscribed to the event, signed with that endpoint's secret.
  4. 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 events subscribes to lead.qualified only; change events (below) are subscribed to by name, once Greenfinch has switched them on for your organization.

The delivery

Request headers

HeaderValue
Content-Typeapplication/json
Greenfinch-Signaturet=<unix seconds>,v1=<hex signature> — see below
Greenfinch-Event-IdThe delivery's ID; the same as event_id in the body.
Greenfinch-Event-Typelead.qualified, or a change event type (below)

Body

POST https://crm.example.com/hooks/greenfinch (abbreviated)
{
  "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": ""
  }
}
FieldMeaning
event_idUnique per delivery to one endpoint, and the same on every retry of it. Your idempotency key.
event_typelead.qualified
created_atWhen this delivery attempt was signed (ISO-8601).
testtrue when the lead was qualified as a test. Always present; filter these out in production.
dataThe 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 typeSent when
signal.ownership_transferA property changed hands (county record).
signal.saleA newer sale was recorded on a property.
signal.manager_changeResearch found a different managing firm.
signal.contact_departureA person left the employer we showed.
signal.contact_title_changeA person's title changed.
signal.contact_employer_changeA person's employer changed.
signal.withdrawnA change already sent to this endpoint was retracted. Names the original event.
A change event (the envelope is the same as above)
{
  "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 later signal.withdrawn names in data.withdraws_event_id. The envelope's event_id stays 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.evidence then holds the changed field and its values before and after, exactly as greenfinch_get_changes shows 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 and manager_withheld: true; exact sale and transfer dates need the premium plan (dates_withheld: true otherwise); 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:

Scheme
Greenfinch-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
  1. Parse t and v1 from the header. Reject a missing or malformed header.
  2. Reject the delivery if t is more than 5 minutes from your clock — it may be a replay.
  3. Compute the HMAC over t + "." + raw body with the full secret (including the whsec_ prefix) and compare it to v1 in 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.

Verify and handle a delivery
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 answersWhat Greenfinch does
Any 2xx within 10 secondsDelivered. The response body is ignored.
410 GoneStops immediately and disables the endpoint — the standard way to unsubscribe from the receiving side.
Any other status, a timeout, or a redirectRetries. 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_id and ignore a delivery you have already processed.
  • Deliveries are not guaranteed to arrive in order. Use data.generated_at to keep the newest version of a lead, and key your CRM record on data.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.