--- title: Leads inmobiliarios por WhatsApp, email y Telegram en una sola bandeja, conectados a tu CRM description: 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. date: 2026-10-19 author: Zavu Team locale: es source: https://www.zavu.dev/es/blog/leads-inmobiliaria-whatsapp tags: Tutorial, API v2, Inmobiliarias, WhatsApp, Email, Telegram, Webhooks --- # Leads inmobiliarios por WhatsApp, email y Telegram en una sola bandeja, conectados a tu CRM ## 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](/es/blog/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: ```bash 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: ```bash 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: ```bash 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: ```bash 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: ```ts 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 ```ts import express from "express"; import { verifyZavu } from "./verify"; import { onNewLead, onLeadMessage } from "./leads"; const app = express(); const processed = new Set(); // 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: ```ts 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: ```ts type Lead = { conversationId: string; channel: string; address: string; name: string | null; text: string }; export async function upsertLeadContact(lead: Lead, propertyId: string | null): Promise { const metadata: Record = { 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`: ```ts 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: ```js // 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ó: ```ts 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: ```ts 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: ```bash # 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](/es/blog/chatbot-ia-tienda-online) explica el agente en detalle, y [cómo crear un agente de soporte con IA](/es/blog/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 ves | Por qué | Qué hacer | |---|---|---| | `401` en cada entrega | Verificas un body re-serializado, o el secret equivocado | Verifica los bytes crudos; rota con `POST /v2/webhook/secret` si perdiste el secret | | `400` en `PUT /v2/webhook` | La URL no es HTTPS, o un evento está mal escrito | Revisa `details.allowed` | | `409 no_channel` al enviar | Nada conectado llega a ese destinatario | `details.connect` dice qué conectar, con la llamada exacta | | `message.failed` con `WHATSAPP_WINDOW_CLOSED` | Texto libre por WhatsApp más de 24 horas después del último mensaje del lead | Envía una plantilla aprobada | | `429 daily_limit_exceeded` | El tope diario de un canal | Se 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](https://docs.zavu.dev/api-v2/errors). ## 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](https://docs.zavu.dev/concepts/sending-limits) 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](https://docs.zavu.dev/api-v2/overview), y los detalles del webhook en [docs.zavu.dev/api-v2/webhook](https://docs.zavu.dev/api-v2/webhook). Si vienes de v1, [Migrar desde v1](https://docs.zavu.dev/api-v2/migrate-from-v1) recorre cada cambio. Para el panorama de una bandeja para todos los canales, lee [qué es una plataforma omnicanal](/es/blog/plataforma-omnichannel).