Webhooks

Un endpoint de webhook es una URL https tuya a la que letsrealty llama cada vez que pasa algo con tus Leads: un Lead nuevo, una edición, un cambio de estado, una nueva consulta, una actividad completada. Las llamadas suelen llegar en menos de un minuto después del cambio.

Eventos

  • lead.created — se creó un Lead, por cualquier vía.
  • lead.updated — cambiaron campos de contacto o del pipeline; data.changed_fields indica cuáles.
  • lead.status_changed — un estado nuevo; data.previous_status tiene el anterior.
  • lead.assigned — un nuevo responsable del Lead; data.previous_assigned_agent_id tiene el anterior.
  • lead.scored — la IA calificó el Lead.
  • lead.archived — se archivó el Lead.
  • inquiry.created — un Lead se puso en contacto; data.lead trae el Lead.
  • activity.completed — se marcó una actividad como hecha.
  • ping — una prueba enviada desde la consola o la API; nunca se suscribe.

Todos los eventos tienen el mismo sobre; data es el recurso completo, nunca un diff, así que nunca necesitas una llamada adicional:

{
  "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", "…": "…" }
}

La referencia de la API documenta cada campo de los payloads en Webhooks.

Registrar un endpoint

Un administrador de la cuenta puede agregar uno en Integraciones → Webhooks en la consola, eligiendo sus eventos. La consola muestra el secreto de firma (whsec_…) una sola vez.

Una integración puede hacer lo mismo con una clave con webhooks:manage, el patrón REST-hook que usa Zapier:

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"] }'

La respuesta 201 trae el id del endpoint y su secret; guarda ambos. DELETE /v1/webhooks/subscriptions/{endpoint_id} cancela la suscripción, y solo funciona con endpoints que suscribió la misma clave. Revocar una clave desactiva los endpoints que suscribió. Las URL deben ser https y accesibles desde internet.

Verificar una entrega

Cada llamada se firma según Standard Webhooks. Lleva tres headers:

  • webhook-id — el id del evento; es el mismo en cada reintento y reenvío, así que úsalo para ignorar duplicados.
  • webhook-timestamp — cuándo se envió este intento, en segundos Unix.
  • webhook-signature — v1, seguido del HMAC-SHA256 en base64 de {webhook-id}.{webhook-timestamp}.{raw body}, con la parte de tu secreto después de whsec_, decodificada de base64, como clave.

Verifica siempre contra el cuerpo sin procesar de la solicitud, antes de parsearlo, y rechaza timestamps que se alejen más de cinco minutos de tu reloj.

Python

Con el paquete standardwebhooks (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)  # lanza una excepción si la firma es incorrecta
    ...

O solo con la biblioteca estándar:

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

Con el paquete standardwebhooks (npm install standardwebhooks), en 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);
  // procesa `event` después de responder
});

O solo con node:crypto:

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");
}

Respuestas, reintentos y reenvíos

Responde con cualquier 2xx en menos de 10 segundos; deja el trabajo lento para después. Cualquier otra cosa —un error, un timeout, una redirección— se reintenta después de 1 minuto, 5 minutos, 30 minutos, 2 horas, 6 horas, 12 horas, 24 horas y 24 horas: unos tres días en total. Los eventos pueden llegar desordenados; compara created_at o los propios campos del recurso cuando importe.

Después de 20 intentos fallidos seguidos, el endpoint se desactiva y se envía un email a los administradores de tu cuenta. Responder 410 Gone lo desactiva de inmediato. Volver a activarlo en la consola (o con PATCH /v1/webhooks/{endpoint_id} y "enabled": true) reinicia el conteo.

Cada intento queda en el registro de entregas del endpoint, en la consola y en GET /v1/webhooks/{endpoint_id}/deliveries. Para enviar uno de nuevo —por ejemplo, después de corregir un bug— usa Reenviar en la consola o POST /v1/webhooks/{endpoint_id}/deliveries/{delivery_id}:redeliver. Un reenvío mantiene el mismo webhook-id. POST /v1/webhooks/{endpoint_id}/test envía un ping.

Ponerse al día

Si tu endpoint estuvo caído más tiempo que los reintentos, GET /v1/events lista los eventos de los últimos 30 días, del más antiguo al más reciente, con el mismo sobre. Envía since —una hora con offset UTC, o el id del último evento que procesaste— y, opcionalmente, type:

curl "https://api.letsrealty.io/v1/events?since=2f6d1c4e-…" \
  -H "Authorization: Bearer $LETSREALTY_API_KEY"

Una clave solo ve los eventos que cubren sus permisos de lectura: leads:read para lead.* e inquiry.*, activities:read para activity.*.

Rotar el secreto

POST /v1/webhooks/{endpoint_id}/rotate-secret (o Rotar secreto en la consola) emite un secreto nuevo. El anterior deja de verificar de inmediato, así que actualiza tu receptor enseguida.