TutorialAPI v2InmobiliariasWhatsAppEmailTelegramWebhooks

Leads inmobiliarios por WhatsApp, email y Telegram en una sola bandeja, conectados a tu CRM

Captura leads inmobiliarios por WhatsApp, email y Telegram con un solo webhook, responde en segundos y envíalos a tu CRM inmobiliario, Google Sheets o Slack.

Redactado por: Zavu TeamRevisado por: Jennifer Villalobos19 de octubre de 202612 min de lectura
Ver como Markdown

TL;DR

  • Un solo webhook recibe todos los leads. conversation.new se dispara con el primer mensaje, llegue por Telegram, por correo o por WhatsApp.
  • Cada lead queda como contacto con el ID de la propiedad y el origen en su metadata, y tu código lo empuja a Google Sheets, Slack o tu CRM.
  • La primera respuesta sale en segundos, en la misma conversación. Envía con conversationId y el canal del lead; una idempotencyKey evita que un webhook reintentado responda dos veces.
  • Tu equipo responde en Conversaciones del dashboard, o desde tu propia app con /v2/conversations.
  • La ventana de 24 horas de WhatsApp sigue aplicando. Un texto libre enviado después falla con WHATSAPP_WINDOW_CLOSED; en ese caso envía una plantilla aprobada.

Un interesado que pregunta por un departamento a las 21:40 y recibe respuesta a la mañana siguiente ya le escribió a otras dos inmobiliarias. Los leads inmobiliarios por WhatsApp, por correo y por Telegram llegan a cualquier hora y por canales distintos: uno toca el botón de WhatsApp del aviso, otro llega reenviado por el portal (Inmuebles24, Portal Inmobiliario, Zonaprop o Fincaraíz, según el país), un tercero escribe a tu bot. El problema no es vender, es contestar primero y no perder ninguno.

En este tutorial juntas los tres canales en una sola bandeja con la API v2 de Zavu, guardas cada lead como contacto con la propiedad que consultó, lo empujas a tu CRM inmobiliario, a Google Sheets o a Slack, y respondes en segundos. Al final vas a tener:

  • Un webhook que recibe cada lead nuevo, de todos los canales que conectes.
  • Un contacto por lead, con el ID de la propiedad y el origen en su metadata.
  • Una fila en Google Sheets, un mensaje en Slack o una llamada a tu CRM: lo decide tu código.
  • Un acuse automático en la misma conversación y por el mismo canal que usó el lead.
  • Tu equipo de corredores respondiendo en Conversaciones del dashboard, o desde tu propia app vía API.

Si es tu primer contacto con v2, el anuncio de Zavu 2.0 explica el modelo: no hay objeto sender, envías desde una dirección, y un solo webhook de proyecto recibe todo.

Qué necesitas

  • Una cuenta de Zavu y una API key. v2 usa las mismas keys que v1. La base es https://api.zavu.dev/v2 y te autenticas con Authorization: Bearer .
  • Al menos un canal que reciba mensajes. Cada uno cuesta distinto en configuración:

- Telegram: un token de bot de @BotFather. Se conecta con una llamada a la API. Sin revisión ni plantillas. - Email: un dominio verificado con registros DNS. Para recibir respuestas, además debe verificar el registro MX de entrada del dominio y la recepción tiene que estar activada para esa dirección. - WhatsApp: el canal con más fricción. Se conecta con el registro de Meta desde Canales en el dashboard, y fuera de la ventana de 24 horas solo puedes escribir con una plantilla aprobada por Meta. Para muchas inmobiliarias es el canal donde llega la mayor parte de las consultas, así que vale el trámite, pero no es el único camino: Telegram y email funcionan desde el primer día.

  • Un endpoint HTTPS público para el webhook. Los ejemplos usan Node 18+ con Express y fetch. Los SDKs llaman a v1 por ahora, así que v2 se llama por HTTP directo.

El SMS de una vía no sirve para esto: el destinatario no puede responder, así que un lead nunca podría escribirte de vuelta.

1. Lista y conecta tus canales

Primero pregunta desde dónde ya puede enviar el proyecto:

terminal
curl https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"
JSON
{ "items": [ { "address": "+525512345678", "type": "whatsapp", "active": true, "v1SenderId": "kd7..." }, { "address": "contacto@inmobiliariapolanco.mx", "type": "email", "active": true, "v1SenderId": "kd7..." } ], "nextCursor": null }

Hay una entrada por dirección y tipo. active: false significa que la conexión existe pero está apagada; la enciendes con POST /v2/channels/{address}/{type}/activate.

Conectar un bot de Telegram:

terminal
curl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "telegram", "botToken": "123456789:AA..."}'

El 201 devuelve la entrada nueva, con dirección @tubot. Si Telegram rechaza el token, la respuesta es 400 invalid_bot_token y no se conecta nada.

Conectar una dirección de email en un dominio ya verificado:

terminal
curl -X POST https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{"type": "email", "address": "contacto@inmobiliariapolanco.mx", "fromName": "Inmobiliaria Polanco"}'

Si el dominio no está en el proyecto recibes 409 email_domain_missing; si está pero sin verificar, 409 email_domain_not_verified. Ambos traen un recommendation con las llamadas exactas (POST /v2/email-domains y luego /verify). Para recibir correos, activa la recepción de esa dirección en el dashboard; solo se acepta cuando el MX de entrada del dominio está verificado.

WhatsApp, Instagram y Messenger no se conectan con esta llamada: POST /v2/channels responde 400 y te indica conectarlos desde el dashboard.

2. Un solo webhook para todos los leads

Suscribe un webhook de proyecto a los dos eventos que importan para leads, y a los fallos:

terminal
curl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://leads.inmobiliariapolanco.mx/webhooks/zavu", "events": ["conversation.new", "message.inbound", "message.failed"] }'
  • conversation.new llega una vez, cuando alguien te escribe por primera vez. Ese es tu lead nuevo.
  • message.inbound llega con cada mensaje que manda un contacto, incluidos los seguimientos.
  • message.failed te avisa cuando una respuesta no salió (lo vemos en el paso 6).

El 201 que crea el webhook devuelve webhook.secret una sola vez. Guárdalo: las lecturas posteriores no lo incluyen, y POST /v2/webhook/secret lo rota. La URL tiene que ser HTTPS, y un nombre de evento que no existe se rechaza con 400, con los válidos en details.allowed.

Así se ve un conversation.new:

JSON
{ "id": "evt_1760900000000_k3j2h1a", "event": "conversation.new", "timestamp": 1760900000000, "projectId": "jx7xyz789ghi012", "from": "+525587654321", "to": "+525512345678", "type": "whatsapp", "data": { "conversationId": "js723987cyghwqxxaxcf590qd18axd95", "phoneNumber": "+525587654321", "channel": "whatsapp", "firstMessageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "firstMessageText": "Hola, ¿sigue disponible el depto de 2 recámaras en Polanco? ID 4521", "profileName": "Lucía Herrera" } }

En los eventos entrantes y en conversation.new, el from de primer nivel es el lead y to es tu dirección. type es el canal, con los mismos nombres que GET /v2/channels. Si el lead llegó por email, data trae email en lugar de phoneNumber.

Verifica la firma

Cada entrega trae un header X-Zavu-Signature como t=1760900000,v2=b4b2.... v2 es HMAC_SHA256(secret, "{t}.{body}") sobre el body crudo, y es el esquema por defecto de un webhook v2 nuevo. Verifícalo antes de confiar en el payload:

TypeScript
import crypto from "node:crypto"; export function verifyZavu(rawBody: Buffer, header: string | undefined, secret: string): boolean { if (!header) return false; const parts = Object.fromEntries( header.split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }), ); const t = Number(parts.t); if (!parts.v2 || !Number.isFinite(t)) return false; // El timestamp va firmado: una entrega vieja no se puede reenviar con uno nuevo. if (Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = crypto .createHmac("sha256", secret) .update(${t}.${rawBody.toString("utf8")}) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v2); return a.length === b.length && crypto.timingSafeEqual(a, b); }

Lee el body crudo. Si tu framework parsea el JSON y tú lo vuelves a serializar, los bytes cambian y la firma nunca coincide.

El receptor

TypeScript
import express from "express"; import { verifyZavu } from "./verify"; import { onNewLead, onLeadMessage } from "./leads"; const app = express(); const processed = new Set<string>(); // en producción, tu base de datos app.post("/webhooks/zavu", express.raw({ type: "application/json" }), async (req, res) => { if (!verifyZavu(req.body, req.header("X-Zavu-Signature"), process.env.ZAVU_WEBHOOK_SECRET!)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString("utf8")); if (processed.has(event.id)) return res.sendStatus(200); try { if (event.event === "conversation.new") await onNewLead(event); if (event.event === "message.inbound") await onLeadMessage(event); if (event.event === "message.failed") console.warn("Envío fallido", event.data); processed.add(event.id); res.sendStatus(200); } catch (err) { console.error(err); res.sendStatus(500); // Zavu reintenta } }); app.listen(3000);

Una entrega que no recibe un 2xx se reintenta, hasta 5 intentos en total (después de 1 minuto, 5 minutos, 15 minutos y 1 hora). Cada evento tiene su propio id, que es lo que usa el set processed.

3. Crea el contacto y manda el lead a tu CRM inmobiliario

Un helper para las llamadas a v2:

TypeScript
const API = "https://api.zavu.dev/v2"; export async function zavu(path: string, init: RequestInit = {}) { const res = await fetch(${API}${path}, { ...init, headers: { Authorization: Bearer ${process.env.ZAVUDEV_API_KEY}, "Content-Type": "application/json", ...init.headers, }, }); return { status: res.status, body: res.status === 204 ? null : await res.json() }; }

¿De dónde sale el ID de la propiedad? Lo más simple es el primer mensaje. El botón "Escríbenos" de tu ficha puede ser un enlace wa.me con texto prellenado (https://wa.me/525512345678?text=Hola%2C%20me%20interesa%20la%20propiedad%20ID%204521), y cuando un portal te reenvía la consulta por correo normalmente incluye el aviso en el texto. Una expresión regular resuelve la mayoría de los casos; guarda lo que encuentre junto al lead y deja que una persona corrija el resto.

La conversación ya conoce a su contacto cuando Zavu lo resolvió. Léelo, y crea el contacto solo si falta:

TypeScript
type Lead = { conversationId: string; channel: string; address: string; name: string | null; text: string }; export async function upsertLeadContact(lead: Lead, propertyId: string | null): Promise<string> { const metadata: Record<string, string> = { source: lead.channel, conversationId: lead.conversationId }; if (propertyId) metadata.propertyId = propertyId; const conv = await zavu(/conversations/${lead.conversationId}); let contactId: string | undefined = conv.body?.conversation?.contactId; if (!contactId) { const created = await zavu("/contacts", { method: "POST", body: JSON.stringify({ displayName: lead.name ?? undefined, channels: [{ channel: lead.channel, identifier: lead.address, isPrimary: true }], metadata, }), }); if (created.status === 201) return created.body.id; if (created.body?.code !== "duplicate_identifier") throw new Error(created.body?.message); contactId = created.body.details.conflicts[0].contactId; } // PATCH reemplaza la metadata completa: combínala con la que ya existe. const current = await zavu(/contacts/${contactId}); await zavu(/contacts/${contactId}, { method: "PATCH", body: JSON.stringify({ metadata: { ...(current.body?.metadata ?? {}), ...metadata } }), }); return contactId!; }

Dos detalles de la API en los que se apoya este código. Los valores de metadata son strings. Y crear un contacto con un identificador que ya tiene otro contacto se rechaza con 400 duplicate_identifier, con el contacto existente en details.conflicts, así que actualizas ese en vez de duplicarlo.

Después, avisa a tu equipo. Un incoming webhook de Slack recibe un JSON con text:

TypeScript
export async function notifySlack(lead: Lead, propertyId: string | null) { await fetch(process.env.SLACK_WEBHOOK_URL!, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: [ Lead nuevo por ${lead.channel}: ${lead.name ?? lead.address}, Propiedad: ${propertyId ?? "no viene en el mensaje"}, > ${lead.text}, https://dashboard.zavu.dev/es/inbox?conv=${lead.conversationId}, ].join("\n"), }), }); }

La última línea es un enlace directo a la conversación en el dashboard.

Para Google Sheets, lo más corto es una web app de Apps Script vinculada a la hoja, que tu servidor llama con un POST:

JavaScript
// Extensiones > Apps Script, luego Implementar > Nueva implementación > Aplicación web function doPost(e) { const token = PropertiesService.getScriptProperties().getProperty("TOKEN"); if (e.parameter.token !== token) return ContentService.createTextOutput("forbidden"); const lead = JSON.parse(e.postData.contents); SpreadsheetApp.getActive().getSheetByName("Leads").appendRow([ new Date(), lead.channel, lead.address, lead.name, lead.propertyId, lead.text, lead.conversationId, ]); return ContentService.createTextOutput("ok"); }

Un CRM inmobiliario tiene la misma forma: una llamada HTTP con el lead, el ID de la propiedad y el conversationId, que es lo que permite a cualquiera abrir la conversación después.

4. Responde en minutos, en la misma conversación

Responde con conversationId y omite to. La respuesta va a la persona de esa conversación, desde la dirección con que se atendió:

TypeScript
export async function acknowledge(lead: Lead) { const res = await zavu("/messages", { method: "POST", body: JSON.stringify({ conversationId: lead.conversationId, channel: lead.channel, text: Gracias${lead.name ? , ${lead.name.split(" ")[0]} : ""}. Un asesor te escribe en breve. Si puedes, cuéntanos qué día te acomoda para visitar la propiedad., ...(lead.channel === "email" ? { subject: "Recibimos tu consulta" } : {}), idempotencyKey: ack-${lead.conversationId}, }), }); if (res.status !== 202 && res.body?.code !== "conflict") throw new Error(res.body?.message); }

Pasar channel mantiene la respuesta en el canal que usó el lead. Una respuesta por email necesita subject. idempotencyKey hace que el acuse sea seguro ante reintentos del webhook: una repetición responde 409 conflict con el mensaje original en details.message y no envía nada.

Si pasas un to que no es la persona de la conversación, la API responde 400 en lugar de abrir otra conversación. El 202 informa cómo se enrutó el mensaje:

JSON
{ "message": { "id": "jx77...", "status": "queued", "channel": "whatsapp", "conversationId": "js723987cyghwqxxaxcf590qd18axd95" }, "routing": { "channel": "whatsapp", "from": "+525512345678", "reason": "explicit", "estimatedCost": null } }

reason es explicit porque se pasó channel. estimatedCost es lo que el envío descontó de tu saldo al aceptarse, algo que hoy solo aplica a SMS; en los demás canales es null.

Todo junto:

TypeScript
export async function onNewLead(event: any) { const lead: Lead = { conversationId: event.data.conversationId, channel: event.type, address: event.from, name: event.data.profileName ?? null, text: event.data.firstMessageText ?? "", }; const propertyId = lead.text.match(/\b(?:ID|REF|C[oó]digo)[\s#:-]*([A-Z0-9-]{3,})/i)?.[1] ?? null; await upsertLeadContact(lead, propertyId); await notifySlack(lead, propertyId); await acknowledge(lead); } export async function onLeadMessage(event: any) { // message.inbound no siempre trae conversationId. El mensaje guardado siempre lo tiene. const { body } = await zavu(/messages/${event.data.messageId}); if (!body?.message?.conversationId) return; // Seguimiento de un lead conocido: avisa al corredor asignado, actualiza el CRM, etc. }

5. Trabaja la conversación: en el dashboard y desde tu app

Tus corredores no necesitan tu código para responder. Cada lead es una conversación en Conversaciones del dashboard, venga del canal que venga. Ahí leen, responden y ven el acuse automático en contexto.

Si tienes tu propia app, las mismas conversaciones están en la API:

terminal
# Conversaciones atendidas por última vez desde tu WhatsApp, las más recientes primero curl "https://api.zavu.dev/v2/conversations?from=%2B525512345678&channel=whatsapp&limit=20" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Buscar un lead por teléfono o email curl "https://api.zavu.dev/v2/conversations?search=lucia%40correo.mx" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Mensajes de una conversación, los más nuevos primero curl "https://api.zavu.dev/v2/conversations/js723987cyghwqxxaxcf590qd18axd95/messages" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Poner en cero sus no leídos curl -X POST "https://api.zavu.dev/v2/conversations/js723987cyghwqxxaxcf590qd18axd95/read" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"

from filtra por la dirección desde la que se atendió por última vez una conversación, channel deja las que pasaron por ese canal, y ambas se paginan con limit (hasta 100) y cursor. search busca por con quién es la conversación: un teléfono, un email, un nombre de usuario de WhatsApp. No busca en el texto de los mensajes, los resultados vienen ordenados por relevancia y no por fecha, y una búsqueda vacía no devuelve nada. Marcar como leída pone unreadCount en cero solo en tu bandeja; el lead no ve ninguna confirmación de lectura.

6. La ventana de 24 horas de WhatsApp

Tu acuse sale dentro de la ventana de WhatsApp, porque el lead acaba de escribir. La ventana importa al día siguiente: si un corredor hace seguimiento más de 24 horas después del último mensaje del lead, WhatsApp rechaza el texto libre.

v2 no revisa la ventana al aceptar el envío. Recibes un 202 y después el mensaje falla: llega message.failed a tu webhook y GET /v2/messages/{messageId} muestra errorCode: "WHATSAPP_WINDOW_CLOSED". Por eso el webhook de arriba se suscribe a message.failed. Fuera de la ventana, envía una plantilla aprobada por Meta (messageType: "template" con content.templateId). Email y Telegram no tienen esa ventana.

Opcional: un agente de IA que precalifica

Un agente puede hacer las preguntas que tu equipo hace siempre: presupuesto, zona, compra o arriendo, cuándo puede visitar. Lo creas con POST /v2/agents (name, provider, model y systemPrompt son obligatorios) y lo conectas a una dirección de GET /v2/channels con POST /v2/agents/{agentId}/channels y {"address": "...", "type": "whatsapp"}. El SMS de una vía se rechaza con 409 channel_cannot_receive, porque nadie puede responder ahí. Si un agente contesta en una dirección, quita el acuse automático para esa dirección o el lead recibirá dos primeras respuestas. El tutorial de chatbot con IA para tienda online explica el agente en detalle, y cómo crear un agente de soporte con IA muestra el flujo desde el dashboard.

Pruébalo, y qué puede fallar

Prueba con una key live y un canal al que puedas escribirte tú: escríbele a tu bot de Telegram desde tu teléfono, o manda un correo a la dirección que conectaste. Una key de test siempre envía desde el sandbox de WhatsApp y no consulta conversationId, así que no ejercita la respuesta del paso 4.

Lo que vesPor quéQué hacer
401 en cada entregaVerificas un body re-serializado, o el secret equivocadoVerifica los bytes crudos; rota con POST /v2/webhook/secret si perdiste el secret
400 en PUT /v2/webhookLa URL no es HTTPS, o un evento está mal escritoRevisa details.allowed
409 no_channel al enviarNada conectado llega a ese destinatariodetails.connect dice qué conectar, con la llamada exacta
message.failed con WHATSAPP_WINDOW_CLOSEDTexto libre por WhatsApp más de 24 horas después del último mensaje del leadEnvía una plantilla aprobada
429 daily_limit_exceededEl tope diario de un canalSe reinicia a las 00:00 UTC; la verificación lo sube

Todos los errores de v2 tienen la misma forma, {code, message, details}; la lista completa está en la referencia de errores.

Pasar a producción

  • Guarda los IDs de evento procesados en tu base de datos, no en memoria, para que un reinicio no reprocese reintentos.
  • Responde rápido al webhook. Si Sheets o tu CRM tardan, pon el lead en una cola y devuelve 200.
  • Los límites de envío y los precios son los de v1, envíe la versión que envíe. Revisa los límites de envío antes de una campaña de lanzamiento de un proyecto.
  • Datos personales. Un teléfono y un correo son datos personales en todos los países de la región, y cada uno tiene su ley de protección de datos. Guarda solo lo que usas para atender al lead, y que tu aviso de privacidad diga para qué.
  • Consentimiento. Un lead que te escribe inició la conversación. Mandar publicidad a quien no te escribió es otra cosa, en cualquier canal.

La referencia de v2 está en docs.zavu.dev/api-v2/overview, y los detalles del webhook en docs.zavu.dev/api-v2/webhook. Si vienes de v1, Migrar desde v1 recorre cada cambio. Para el panorama de una bandeja para todos los canales, lee qué es una plataforma omnicanal.

Preguntas frecuentes

¿Cómo recibo leads inmobiliarios por WhatsApp, email y Telegram en un solo lugar?
Conecta cada canal a un proyecto de Zavu y suscribe un solo webhook con PUT /v2/webhook a conversation.new y message.inbound. El primer mensaje de cada lead nuevo llega a la misma URL, con el canal en el campo type, el lead en from y un conversationId para responder y abrir la conversación.
¿Cómo conecto WhatsApp a mi CRM inmobiliario?
Con el webhook de Zavu en el medio. Cuando llega conversation.new, tu código guarda el contacto con PATCH /v2/contacts/{contactId} y llama a la API de tu CRM con el lead, el ID de la propiedad y el conversationId. En este tutorial esa llamada la escribes tú, así que sirve para cualquier CRM con API.
¿Qué tan rápido puede salir la primera respuesta a un lead?
Tan rápido como corra tu webhook. Al recibir conversation.new, envía POST /v2/messages con el conversationId y el mismo canal, sin campo to. La respuesta llega al lead por el canal que usó. Agrega un idempotencyKey para que un reintento responda 409 conflict en lugar de enviar dos veces.
¿Mis corredores pueden responder sin usar la API?
Sí. Cada lead es una conversación en Conversaciones del dashboard de Zavu, venga del canal que venga, y los corredores leen y responden ahí. Tu propia app puede trabajar las mismas conversaciones con GET /v2/conversations, GET /v2/conversations/{id}/messages y POST /v2/conversations/{id}/read.
¿Qué pasa si respondo por WhatsApp después de 24 horas?
El mensaje de texto libre falla. Zavu acepta el envío con 202 y luego el mensaje pasa a failed con errorCode WHATSAPP_WINDOW_CLOSED, visible en el webhook message.failed y en GET /v2/messages/{messageId}. Pasadas 24 horas desde el último mensaje del lead, envía una plantilla aprobada por Meta.
¿Necesito WhatsApp para empezar?
No. WhatsApp es el canal con más trámite, porque requiere el registro de Meta y plantillas aprobadas para escribir fuera de la ventana de 24 horas. Telegram se conecta con un token de bot y email con un dominio verificado, ambos con una llamada a POST /v2/channels. Puedes sumar WhatsApp después sin cambiar el webhook.

Necesitas ayuda? Contáctanos o únete a nuestra comunidad en Discord para soporte.

Get started

Listo para empezar?

Comienza a construir gratis, o agenda una llamada para discutir tu caso de uso específico.

Leads inmobiliarios por WhatsApp y tu CRM | Zavu Blog