TutorialAPI v2ImobiliáriasWhatsAppE-mailTelegramWebhooks

Leads imobiliários pelo WhatsApp, e-mail e Telegram numa caixa só, ligados ao seu CRM

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.

Escrito por: Zavu TeamRevisado por: Jennifer Villalobos19 de outubro de 202612 min de leitura
Ver como Markdown

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 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:

terminal
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:

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

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:

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": "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:

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.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:

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; // 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

TypeScript
import express from "express"; import { verifyZavu } from "./verify"; import { onNewLead, onLeadMessage } from "./leads"; const app = express(); const processed = new Set<string>(); // 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:

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 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:

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; } // 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:

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 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:

JavaScript
// 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:

TypeScript
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:

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(?: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:

terminal
# 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 explica o agente em detalhe, e mensagem automática no 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 entregaVocê verifica um body serializado de novo, ou o secret erradoVerifique os bytes crus; faça a rotação com POST /v2/webhook/secret se perdeu o secret
400 no PUT /v2/webhookA URL não é HTTPS, ou um evento está escrito erradoConfira details.allowed
409 no_channel ao enviarNada conectado alcança esse destinatáriodetails.connect diz o que conectar, com a chamada exata
message.failed com WHATSAPP_WINDOW_CLOSEDTexto livre no WhatsApp mais de 24 horas depois da última mensagem do leadEnvie um template aprovado
429 daily_limit_exceededO teto diário de um canalZera à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.

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 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, e os detalhes do webhook em docs.zavu.dev/api-v2/webhook. Se você vem da v1, Migrar da v1 mostra cada mudança. Para o panorama de uma caixa de entrada para todos os canais, leia o que é uma plataforma omnichannel.

Perguntas frequentes

Como recebo leads imobiliários pelo WhatsApp, e-mail e Telegram num lugar só?
Conecte cada canal a um projeto da Zavu e inscreva um único webhook com PUT /v2/webhook em conversation.new e message.inbound. A primeira mensagem de cada lead novo chega na mesma URL, com o canal no campo type, o lead em from e um conversationId para responder e abrir a conversa.
Como ligo o WhatsApp ao meu CRM para imobiliária?
Com o webhook da Zavu no meio. Quando chega conversation.new, o seu código salva o contato com PATCH /v2/contacts/{contactId} e chama a API do seu CRM com o lead, o código do imóvel e o conversationId. Neste tutorial essa chamada é você quem escreve, então serve para qualquer CRM com API.
Em quanto tempo sai a primeira resposta para um lead?
No tempo que o seu webhook levar. Ao receber conversation.new, envie POST /v2/messages com o conversationId e o mesmo canal, sem o campo to. A resposta chega ao lead pelo canal que ele usou. Inclua uma idempotencyKey para que um reenvio responda 409 conflict em vez de mandar duas vezes.
Os corretores podem responder sem usar a API?
Sim. Cada lead é uma conversa em Conversas no dashboard da Zavu, venha do canal que vier, e os corretores leem e respondem lá. O seu próprio app pode atender as mesmas conversas com GET /v2/conversations, GET /v2/conversations/{id}/messages e POST /v2/conversations/{id}/read.
O que acontece se eu responder no WhatsApp depois de 24 horas?
A mensagem de texto livre falha. A Zavu aceita o envio com 202 e depois a mensagem passa a failed com errorCode WHATSAPP_WINDOW_CLOSED, visível no webhook message.failed e em GET /v2/messages/{messageId}. Passadas 24 horas da última mensagem do lead, envie um template aprovado pela Meta.
O que a LGPD muda para guardar leads?
Telefone e e-mail de um lead são dados pessoais, e a LGPD exige uma base legal para tratá-los; o consentimento é uma delas. Na prática: guarde só o que você usa para atender o lead, diga na política de privacidade para que serve, e trate envio de publicidade como algo separado de responder a quem procurou você.

Precisa de ajuda? Contate-nos ou junte-se à nossa comunidade Discord para suporte.

Get started

Pronto para começar?

Comece a construir gratuitamente ou agende uma chamada para discutir seu caso de uso específico.

Leads imobiliários pelo WhatsApp e o seu CRM | Zavu Blog