TL;DR
- Um único webhook recebe todos os leads.
conversation.newdispara 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
conversationIde o canal do lead; umaidempotencyKeyimpede 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/v2e 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:
terminalcurl 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:
terminalcurl -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:
terminalcurl -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:
terminalcurl -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.newchega uma vez, quando alguém escreve para você pela primeira vez. Esse é o lead novo.message.inboundchega a cada mensagem que um contato envia, incluindo os retornos.message.failedavisa 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:
TypeScriptimport 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() .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(parts.v2); return a.length === b.length && crypto.timingSafeEqual(a, b); }${t}.${rawBody.toString("utf8")}
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
TypeScriptimport 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:
TypeScriptconst API = "https://api.zavu.dev/v2"; export async function zavu(path: string, init: RequestInit = {}) { const res = await fetch(, { ...init, headers: { Authorization:${API}${path}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:
TypeScripttype 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(); 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(/conversations/${lead.conversationId}/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:
TypeScriptexport 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:
TypeScriptexport async function acknowledge(lead: Lead) { const res = await zavu("/messages", { method: "POST", body: JSON.stringify({ conversationId: lead.conversationId, channel: lead.channel, text:, ${lead.name.split(" ")[0]}Obrigado${lead.name ?: ""}. 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:
TypeScriptexport 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(); if (!body?.message?.conversationId) return; // Retorno de um lead conhecido: avise o corretor responsável, atualize o CRM, etc. }/messages/${event.data.messageId}
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 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.
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.