--- title: Leads imobiliários pelo WhatsApp, e-mail e Telegram numa caixa só, ligados ao seu CRM description: Capture leads imobiliários pelo WhatsApp, e-mail e Telegram com um webhook só, responda em segundos e envie para o seu CRM para imobiliária, Sheets ou Slack. date: 2026-10-19 author: Zavu Team locale: pt source: https://www.zavu.dev/pt/blog/leads-inmobiliaria-whatsapp tags: Tutorial, API v2, Imobiliárias, WhatsApp, E-mail, Telegram, Webhooks --- # Leads imobiliários pelo WhatsApp, e-mail e Telegram numa caixa só, ligados ao seu CRM ## TL;DR - **Um único webhook recebe todos os leads.** `conversation.new` dispara na primeira mensagem, venha ela pelo Telegram, por e-mail ou pelo WhatsApp. - **Cada lead vira um contato** com o código do imóvel e a origem na metadata, e o seu código envia para o Google Sheets, o Slack ou o seu CRM. - **A primeira resposta sai em segundos, na mesma conversa.** Envie com `conversationId` e o canal do lead; uma `idempotencyKey` impede que um webhook repetido responda duas vezes. - **Sua equipe responde em Conversas** no dashboard, ou pelo seu próprio app com `/v2/conversations`. - **A janela de 24 horas do WhatsApp continua valendo.** Um texto livre enviado depois falha com `WHATSAPP_WINDOW_CLOSED`; nesse caso, envie um template aprovado. Quem pergunta por um apartamento às 21h40 e só recebe resposta na manhã seguinte já falou com outras duas imobiliárias. Os leads imobiliários pelo WhatsApp, por e-mail e pelo Telegram chegam a qualquer hora e por canais diferentes: um toca no botão de WhatsApp do anúncio, outro chega encaminhado pelo portal (ZAP Imóveis, VivaReal, OLX), um terceiro escreve para o seu bot. O difícil não é vender, é responder primeiro e não perder nenhum. Neste tutorial você junta os três canais numa caixa de entrada só com a API v2 da Zavu, salva cada lead como contato com o imóvel que ele consultou, envia para o seu CRM para imobiliária, para o Google Sheets ou para o Slack, e responde em segundos. No fim você vai ter: - Um webhook que recebe cada lead novo, de todos os canais que você conectar. - Um contato por lead, com o código do imóvel e a origem na metadata. - Uma linha no Google Sheets, uma mensagem no Slack ou uma chamada ao seu CRM: o seu código decide. - Uma confirmação automática na mesma conversa e pelo mesmo canal que o lead usou. - Seus corretores respondendo em **Conversas** no dashboard, ou pelo seu próprio app via API. Se é a primeira vez que você vê a v2, o [anúncio da Zavu 2.0](/pt/blog/zavu-2-0) explica o modelo: não existe objeto sender, você envia a partir de um endereço, e um único webhook de projeto recebe tudo. ## O que você precisa - **Uma conta na Zavu e uma API key.** A v2 usa as mesmas keys da v1. A base é `https://api.zavu.dev/v2` e a autenticação é `Authorization: Bearer `. - **Pelo menos um canal que receba mensagens.** Cada um tem um custo de configuração diferente: - **Telegram**: um token de bot do @BotFather. Conecta com uma chamada à API. Sem revisão e sem template. - **E-mail**: um domínio verificado com registros DNS. Para receber respostas, o registro MX de entrada do domínio também precisa ser verificado e o recebimento tem de estar ativado para o endereço. - **WhatsApp**: o canal com mais atrito. Conecta pelo cadastro da Meta em **Canais** no dashboard, e fora da janela de 24 horas você só pode escrever com um template aprovado pela Meta. Para muitas imobiliárias é por onde chega a maior parte das consultas, então vale o processo, mas não é o único caminho: Telegram e e-mail funcionam desde o primeiro dia. - **Um endpoint HTTPS público** para o webhook. Os exemplos usam Node 18+ com Express e `fetch`. Os SDKs chamam a v1 por enquanto, então a v2 é chamada por HTTP direto. O SMS de uma via não serve aqui: o destinatário não consegue responder, então um lead nunca poderia escrever de volta. ## 1. Liste e conecte seus canais Primeiro, pergunte de onde o projeto já pode enviar: ```bash curl https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```json { "items": [ { "address": "+5511912345678", "type": "whatsapp", "active": true, "v1SenderId": "kd7..." }, { "address": "contato@imobiliariapinheiros.com.br", "type": "email", "active": true, "v1SenderId": "kd7..." } ], "nextCursor": null } ``` Há uma entrada por endereço e tipo. `active: false` significa que a conexão existe mas está desligada; ligue com `POST /v2/channels/{address}/{type}/activate`. Conectar um bot do 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..."}' ``` O `201` devolve a entrada nova, com endereço `@seubot`. Se o Telegram rejeitar o token, a resposta é `400 invalid_bot_token` e nada é conectado. Conectar um endereço de e-mail num domínio já 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": "contato@imobiliariapinheiros.com.br", "fromName": "Imobiliária Pinheiros"}' ``` Se o domínio não está no projeto, você recebe `409 email_domain_missing`; se está mas não foi verificado, `409 email_domain_not_verified`. Os dois trazem um `recommendation` com as chamadas exatas (`POST /v2/email-domains` e depois `/verify`). Para receber e-mails, ative o recebimento do endereço no dashboard; ele só é aceito quando o MX de entrada do domínio está verificado. WhatsApp, Instagram e Messenger não se conectam com essa chamada: `POST /v2/channels` responde `400` e indica conectá-los pelo dashboard. ## 2. Um webhook só para todos os leads Inscreva um webhook de projeto nos dois eventos que importam para leads, e nas falhas: ```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.imobiliariapinheiros.com.br/webhooks/zavu", "events": ["conversation.new", "message.inbound", "message.failed"] }' ``` - `conversation.new` chega uma vez, quando alguém escreve para você pela primeira vez. Esse é o lead novo. - `message.inbound` chega a cada mensagem que um contato envia, incluindo os retornos. - `message.failed` avisa quando uma resposta não saiu (veja o passo 6). O `201` que cria o webhook devolve `webhook.secret` **uma única vez**. Guarde: leituras posteriores não trazem o secret, e `POST /v2/webhook/secret` faz a rotação. A URL precisa ser HTTPS, e um nome de evento que não existe é recusado com `400`, com os válidos em `details.allowed`. Um `conversation.new` chega assim: ```json { "id": "evt_1760900000000_k3j2h1a", "event": "conversation.new", "timestamp": 1760900000000, "projectId": "jx7xyz789ghi012", "from": "+5511987654321", "to": "+5511912345678", "type": "whatsapp", "data": { "conversationId": "js723987cyghwqxxaxcf590qd18axd95", "phoneNumber": "+5511987654321", "channel": "whatsapp", "firstMessageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "firstMessageText": "Oi, o apartamento de 2 quartos em Pinheiros ainda está disponível? Cód 4521", "profileName": "Mariana Souza" } } ``` Nos eventos de entrada e no `conversation.new`, o `from` do nível superior é o lead e `to` é o seu endereço. `type` é o canal, com os mesmos nomes de `GET /v2/channels`. Se o lead chegou por e-mail, `data` traz `email` no lugar de `phoneNumber`. ### Verifique a assinatura Toda entrega traz um header `X-Zavu-Signature` como `t=1760900000,v2=b4b2...`. `v2` é `HMAC_SHA256(secret, "{t}.{body}")` sobre o body cru, e é o esquema padrão de um webhook v2 novo. Verifique antes de confiar no 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; // O timestamp é assinado: uma entrega antiga não pode ser reenviada com outro. 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); } ``` Leia o body cru. Se o seu framework faz o parse do JSON e você serializa de novo, os bytes mudam e a assinatura nunca bate. ### O receptor ```ts import express from "express"; import { verifyZavu } from "./verify"; import { onNewLead, onLeadMessage } from "./leads"; const app = express(); const processed = new Set(); // em produção, o seu banco de dados 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("Envio falhou", event.data); processed.add(event.id); res.sendStatus(200); } catch (err) { console.error(err); res.sendStatus(500); // a Zavu tenta de novo } }); app.listen(3000); ``` Uma entrega que não recebe `2xx` é reenviada, até 5 tentativas no total (depois de 1 minuto, 5 minutos, 15 minutos e 1 hora). Cada evento tem o seu próprio `id`, que é o que o set `processed` usa. ## 3. Crie o contato e envie o lead para o seu CRM para imobiliária Um helper para as chamadas à 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 onde vem o código do imóvel? O mais simples é a primeira mensagem. O botão "Fale conosco" do seu anúncio pode ser um link `wa.me` com texto pré-preenchido (`https://wa.me/5511912345678?text=Oi%2C%20tenho%20interesse%20no%20im%C3%B3vel%20c%C3%B3d%204521`), e quando um portal encaminha a consulta por e-mail normalmente o anúncio vem no texto. Uma expressão regular resolve a maioria dos casos; guarde o que ela encontrar junto do lead e deixe uma pessoa corrigir o resto. A conversa já conhece o contato quando a Zavu o resolveu. Leia, e crie o contato só se ele faltar: ```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; } // O PATCH substitui a metadata inteira: junte com a que já existe. const current = await zavu(`/contacts/${contactId}`); await zavu(`/contacts/${contactId}`, { method: "PATCH", body: JSON.stringify({ metadata: { ...(current.body?.metadata ?? {}), ...metadata } }), }); return contactId!; } ``` Dois detalhes da API em que esse código se apoia. Os valores de metadata são strings. E criar um contato com um identificador que outro contato já tem é recusado com `400 duplicate_identifier`, com o contato existente em `details.conflicts`, então você atualiza esse em vez de duplicar. Depois, avise a equipe. Um incoming webhook do Slack recebe um JSON com `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 novo por ${lead.channel}: ${lead.name ?? lead.address}`, `Imóvel: ${propertyId ?? "não veio na mensagem"}`, `> ${lead.text}`, `https://dashboard.zavu.dev/pt-BR/inbox?conv=${lead.conversationId}`, ].join("\n"), }), }); } ``` A última linha é um link direto para a conversa no dashboard. Para o Google Sheets, o caminho mais curto é um app da web do Apps Script vinculado à planilha, que o seu servidor chama com um POST: ```js // Extensões > Apps Script, depois Implantar > Nova implantação > App da 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"); } ``` Um CRM para imobiliária tem o mesmo formato: uma chamada HTTP com o lead, o código do imóvel e o `conversationId`, que é o que permite a qualquer pessoa abrir a conversa depois. ## 4. Responda em minutos, na mesma conversa Responda com `conversationId` e deixe `to` de fora. A resposta vai para a pessoa daquela conversa, a partir do endereço que a atendeu: ```ts export async function acknowledge(lead: Lead) { const res = await zavu("/messages", { method: "POST", body: JSON.stringify({ conversationId: lead.conversationId, channel: lead.channel, text: `Obrigado${lead.name ? `, ${lead.name.split(" ")[0]}` : ""}. Um corretor fala com você em breve. Se puder, conte qual dia é melhor para visitar o imóvel.`, ...(lead.channel === "email" ? { subject: "Recebemos a sua consulta" } : {}), idempotencyKey: `ack-${lead.conversationId}`, }), }); if (res.status !== 202 && res.body?.code !== "conflict") throw new Error(res.body?.message); } ``` Passar `channel` mantém a resposta no canal que o lead usou. Uma resposta por e-mail precisa de `subject`. A `idempotencyKey` deixa a confirmação segura diante de reenvios do webhook: uma repetição responde `409 conflict` com a mensagem original em `details.message` e não envia nada. Se você passar um `to` que não é a pessoa da conversa, a API responde `400` em vez de abrir outra conversa. O `202` informa como a mensagem foi roteada: ```json { "message": { "id": "jx77...", "status": "queued", "channel": "whatsapp", "conversationId": "js723987cyghwqxxaxcf590qd18axd95" }, "routing": { "channel": "whatsapp", "from": "+5511912345678", "reason": "explicit", "estimatedCost": null } } ``` `reason` é `explicit` porque `channel` foi informado. `estimatedCost` é o que o envio descontou do seu saldo ao ser aceito, o que hoje vale só para SMS; nos outros canais é `null`. Tudo 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(?:C[oó]d(?:igo)?|REF|ID)[\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) { // O message.inbound nem sempre traz conversationId. A mensagem salva sempre tem. const { body } = await zavu(`/messages/${event.data.messageId}`); if (!body?.message?.conversationId) return; // Retorno de um lead conhecido: avise o corretor responsável, atualize o CRM, etc. } ``` ## 5. Atenda a conversa: no dashboard e pelo seu app Seus corretores não precisam do seu código para responder. Cada lead é uma conversa em **Conversas** no dashboard, venha do canal que vier. Lá eles leem, respondem e veem a confirmação automática no contexto. Se você tem o seu próprio app, as mesmas conversas estão na API: ```bash # Conversas atendidas por último a partir do seu WhatsApp, as mais recentes primeiro curl "https://api.zavu.dev/v2/conversations?from=%2B5511912345678&channel=whatsapp&limit=20" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Encontrar um lead por telefone ou e-mail curl "https://api.zavu.dev/v2/conversations?search=mariana%40email.com.br" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Mensagens de uma conversa, as mais novas primeiro curl "https://api.zavu.dev/v2/conversations/js723987cyghwqxxaxcf590qd18axd95/messages" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Zerar as não lidas curl -X POST "https://api.zavu.dev/v2/conversations/js723987cyghwqxxaxcf590qd18axd95/read" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` `from` filtra pelo endereço que atendeu a conversa por último, `channel` mantém as que passaram por esse canal, e as duas paginam com `limit` (até 100) e `cursor`. `search` procura por com quem é a conversa: um telefone, um e-mail, um nome de usuário do WhatsApp. Não procura no texto das mensagens, os resultados vêm por relevância e não por data, e uma busca vazia não devolve nada. Marcar como lida zera `unreadCount` só na sua caixa de entrada; o lead não recebe nenhuma confirmação de leitura. ## 6. A janela de 24 horas do WhatsApp A sua confirmação sai dentro da janela do WhatsApp, porque o lead acabou de escrever. A janela importa no dia seguinte: se um corretor retoma o contato mais de 24 horas depois da última mensagem do lead, o WhatsApp recusa texto livre. A v2 não confere a janela ao aceitar o envio. Você recebe um `202` e depois a mensagem falha: chega `message.failed` no seu webhook e `GET /v2/messages/{messageId}` mostra `errorCode: "WHATSAPP_WINDOW_CLOSED"`. Por isso o webhook acima assina `message.failed`. Fora da janela, envie um template aprovado pela Meta (`messageType: "template"` com `content.templateId`). E-mail e Telegram não têm essa janela. ## Opcional: um agente de IA para qualificar Um agente pode fazer as perguntas que a sua equipe faz sempre: orçamento, bairro, compra ou aluguel, quando pode visitar. Crie com `POST /v2/agents` (`name`, `provider`, `model` e `systemPrompt` são obrigatórios) e conecte a um endereço de `GET /v2/channels` com `POST /v2/agents/{agentId}/channels` e `{"address": "...", "type": "whatsapp"}`. O SMS de uma via é recusado com `409 channel_cannot_receive`, porque ninguém consegue responder lá. Se um agente responde num endereço, tire a confirmação automática desse endereço ou o lead vai receber duas primeiras respostas. O tutorial de [chatbot com IA para loja virtual](/pt/blog/chatbot-ia-tienda-online) explica o agente em detalhe, e [mensagem automática no WhatsApp Business](/pt/blog/mensagem-automatica-whatsapp-business) cobre o lado do WhatsApp. ## Teste, e o que pode dar errado Teste com uma key live e um canal para o qual você mesmo possa escrever: mande uma mensagem ao seu bot do Telegram pelo celular, ou um e-mail para o endereço que você conectou. Uma key de teste sempre envia pelo sandbox do WhatsApp e não consulta `conversationId`, então não exercita a resposta do passo 4. | O que você vê | Por quê | O que fazer | |---|---|---| | `401` em toda entrega | Você verifica um body serializado de novo, ou o secret errado | Verifique os bytes crus; faça a rotação com `POST /v2/webhook/secret` se perdeu o secret | | `400` no `PUT /v2/webhook` | A URL não é HTTPS, ou um evento está escrito errado | Confira `details.allowed` | | `409 no_channel` ao enviar | Nada conectado alcança esse destinatário | `details.connect` diz o que conectar, com a chamada exata | | `message.failed` com `WHATSAPP_WINDOW_CLOSED` | Texto livre no WhatsApp mais de 24 horas depois da última mensagem do lead | Envie um template aprovado | | `429 daily_limit_exceeded` | O teto diário de um canal | Zera às 00:00 UTC; a verificação aumenta o teto | Todos os erros da v2 têm o mesmo formato, `{code, message, details}`; a lista completa está na [referência de erros](https://docs.zavu.dev/api-v2/errors). ## Indo para produção - **Guarde os IDs de evento processados** no banco de dados, não em memória, para que um reinício não reprocesse reenvios. - **Responda rápido ao webhook.** Se o Sheets ou o CRM demoram, coloque o lead numa fila e devolva `200`. - **Os limites de envio e os preços são os da v1**, qualquer que seja a versão que envia. Veja os [limites de envio](https://docs.zavu.dev/concepts/sending-limits) antes do lançamento de um empreendimento. - **LGPD.** Telefone e e-mail são dados pessoais, e a LGPD pede uma base legal para tratá-los; o consentimento é uma delas. Guarde só o que você usa para atender o lead e diga na sua política de privacidade para que serve. - **Quem começou a conversa.** Um lead que escreve para você iniciou o contato. Mandar publicidade para quem não escreveu é outra coisa, em qualquer canal. A referência da v2 está em [docs.zavu.dev/api-v2/overview](https://docs.zavu.dev/api-v2/overview), e os detalhes do webhook em [docs.zavu.dev/api-v2/webhook](https://docs.zavu.dev/api-v2/webhook). Se você vem da v1, [Migrar da v1](https://docs.zavu.dev/api-v2/migrate-from-v1) mostra cada mudança. Para o panorama de uma caixa de entrada para todos os canais, leia [o que é uma plataforma omnichannel](/pt/blog/plataforma-omnichannel).