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_fields lists which.
  • lead.status_changed — a new status; data.previous_status has the old one.
  • lead.assigned — a new Lead Owner; data.previous_assigned_agent_id has the old one.
  • lead.scored — AI scored the Lead.
  • lead.archived — the Lead was archived.
  • inquiry.created — a Lead got in touch; data.lead carries 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 event id; 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 after whsec_.

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.