TutorialAPI v2Real EstateWhatsAppEmailTelegramWebhooks

Real Estate Lead Response Automation: WhatsApp, Email and Telegram in One Inbox

Real estate lead response automation with one webhook: capture leads from WhatsApp, email and Telegram, reply in seconds, and push them to Sheets or Slack.

Written by: Zavu TeamReviewed by: Jennifer VillalobosOctober 19, 202612 min read
View as Markdown

TL;DR

  • One webhook receives every lead. conversation.new fires on a first message, whether it arrived by Telegram, email or WhatsApp.
  • Each lead becomes a contact with the property id and the source in its metadata, and your code pushes it to Google Sheets, Slack or your CRM.
  • The first reply goes out in seconds, in the same thread. Send with conversationId and the lead's channel; an idempotencyKey keeps a retried webhook from replying twice.
  • Your team answers in Conversations in the dashboard, or from your own app through /v2/conversations.
  • WhatsApp's 24-hour window still applies. Free-form text sent later fails with WHATSAPP_WINDOW_CLOSED; send an approved template instead.

A buyer who asks about a listing at 9:40 pm and hears back the next morning has usually asked two other agents too. Real estate lead response automation fixes the slow part, the first reply, and leaves the selling to people. The hard part is that leads do not arrive in one place: one writes on WhatsApp from a listing link, another forwards a portal inquiry by email, a third finds your Telegram bot.

This tutorial puts all three into one inbox with Zavu API v2, writes each lead to a contact with the property it asked about, pushes a row to Google Sheets or a message to Slack, and sends a first reply within seconds. At the end you will have:

  • One webhook that receives every new lead, from every channel you connect.
  • A contact per lead, tagged with the property id and the source.
  • A row in a Google Sheet, a message in Slack, or a call to your CRM: your code decides.
  • An automatic acknowledgment in the same thread, on the channel the lead used.
  • Your team answering in Conversations in the dashboard, or from your own app through the API.

If you are new to v2, the Zavu 2.0 announcement explains the model: there is no sender object, you send from an address, and one project webhook receives everything.

What you need

  • A Zavu account and an API key. v2 uses the same keys as v1. The base URL is https://api.zavu.dev/v2 and you authenticate with Authorization: Bearer .
  • At least one channel that receives messages. Each one has a different setup cost:

- Telegram: a bot token from @BotFather. Connected with one API call. No review, no template. - Email: a domain you verify with DNS records. To receive replies, the domain's inbound MX record also has to verify, and receiving is turned on for the address. - WhatsApp: the most friction. It is connected through Meta's signup from Channels in the dashboard, and messages outside the 24-hour window need a template Meta has approved. - SMS in the US: two-way SMS needs a number of your own (a paid plan), and every SMS to a +1 number needs an approved 10DLC campaign. Without one, the send is refused with 403 ten_dlc_required. Email and Telegram need none of this. If texting is your main channel, read what A2P 10DLC is before you plan around it.

  • A public HTTPS endpoint for the webhook. The examples use Node 18+ with Express and fetch. The SDKs call v1 today, so v2 is called over plain HTTP.

One-way SMS is not useful here: recipients cannot reply to it, so a lead can never write back.

1. List and connect your channels

Start by asking what the project can already send from:

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

There is one entry per address and type. active: false means the connection exists but is switched off; turn it on with POST /v2/channels/{address}/{type}/activate.

Connect a Telegram bot:

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

The 201 lists the new entry, addressed as @yourbot. A token Telegram rejects answers 400 invalid_bot_token and connects nothing.

Connect an email address on a domain you already verified:

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": "leads@brokerage.com", "fromName": "Oak Street Realty"}'

If the domain is not in the project you get 409 email_domain_missing; if it is there but not verified, 409 email_domain_not_verified. Both carry a recommendation with the exact calls (POST /v2/email-domains, then /verify). To receive email, turn receiving on for the address in the dashboard; it is accepted only once the domain's inbound MX record verifies.

WhatsApp, Instagram and Messenger are not connected with this call: POST /v2/channels answers 400 and says to connect them from the dashboard.

2. One webhook for every lead

Subscribe one project webhook to the two events that matter for leads, plus failures:

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.brokerage.com/webhooks/zavu", "events": ["conversation.new", "message.inbound", "message.failed"] }'
  • conversation.new fires once, when someone writes to you for the first time. That is your new lead.
  • message.inbound fires on every message a contact sends, including follow-ups.
  • message.failed tells you when a reply did not go out (more on this in step 6).

The 201 that creates the webhook returns webhook.secret once. Store it; later reads do not return it, and POST /v2/webhook/secret rotates it. The URL must be HTTPS, and an unknown event name is refused with 400 listing the valid ones in details.allowed.

A conversation.new delivery looks like this:

JSON
{ "id": "evt_1760900000000_k3j2h1a", "event": "conversation.new", "timestamp": 1760900000000, "projectId": "jx7xyz789ghi012", "from": "+14155550134", "to": "+13125551212", "type": "whatsapp", "data": { "conversationId": "js723987cyghwqxxaxcf590qd18axd95", "phoneNumber": "+14155550134", "channel": "whatsapp", "firstMessageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "firstMessageText": "Hi, is the 2BR on Oak St still available? REF 4521", "profileName": "Dana Brooks" } }

On inbound events and conversation.new, top-level from is the lead and to is your address. type is the channel, with the same names as GET /v2/channels. For a lead that arrived by email, data carries email instead of phoneNumber.

Verify the signature

Every delivery has an X-Zavu-Signature header such as t=1760900000,v2=b4b2.... v2 is HMAC_SHA256(secret, "{t}.{body}") over the raw body, and it is the default for a new v2 webhook. Verify it before you trust anything in the 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; // The timestamp is signed, so a stale delivery cannot be replayed with a new one. 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); }

Read the body raw. If your framework parses JSON first and you re-serialize it, the bytes change and the signature never matches.

The receiver

TypeScript
import express from "express"; import { verifyZavu } from "./verify"; import { onNewLead, onLeadMessage } from "./leads"; const app = express(); const processed = new Set<string>(); // use your database in production 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("Send failed", event.data); processed.add(event.id); res.sendStatus(200); } catch (err) { console.error(err); res.sendStatus(500); // Zavu retries } }); app.listen(3000);

A delivery that does not get a 2xx is retried, up to 5 attempts in total (after 1 minute, 5 minutes, 15 minutes and 1 hour). Each event has its own id, which is what the processed set keys on.

3. Create the contact and push the lead to your tools

A small helper for v2 calls:

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() }; }

Where does the property id come from? The simplest source is the first message itself. A "Message us" button on a listing can be a wa.me link with prefilled text (https://wa.me/13125551212?text=Hi%2C%20I%27m%20asking%20about%20REF%204521), and a portal that forwards inquiries by email usually puts the listing in the text. A regular expression gets you most of the way; keep what it finds next to the lead, and let a person fix the rest.

The conversation already knows its contact when Zavu has resolved one. Read it, and create the contact only when it is missing:

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 replaces metadata as a whole: merge with what is already there. const current = await zavu(/contacts/${contactId}); await zavu(/contacts/${contactId}, { method: "PATCH", body: JSON.stringify({ metadata: { ...(current.body?.metadata ?? {}), ...metadata } }), }); return contactId!; }

Two details from the API that this code relies on. Metadata values are strings. And creating a contact for an identifier another contact already holds is refused with 400 duplicate_identifier, with the existing contact in details.conflicts, so you update that one instead of creating a duplicate.

Then hand the lead to your team. A Slack incoming webhook takes a JSON body with 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: [ New lead on ${lead.channel}: ${lead.name ?? lead.address}, Property: ${propertyId ?? "not found in the message"}, > ${lead.text}, https://dashboard.zavu.dev/en/inbox?conv=${lead.conversationId}, ].join("\n"), }), }); }

The last line is a direct link to the thread in the dashboard.

For a Google Sheet, the shortest path is an Apps Script web app bound to the sheet, which your server calls with a POST:

JavaScript
// Extensions > Apps Script, then Deploy > New deployment > Web app 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"); }

A CRM is the same shape: one HTTP call with the lead, the property id and the conversationId, which is what lets anyone open the thread later.

4. Answer within minutes, in the same thread

Reply with conversationId and leave to out. The reply goes to whoever the thread is with, from the address that conversation was handled from:

TypeScript
export async function acknowledge(lead: Lead) { const res = await zavu("/messages", { method: "POST", body: JSON.stringify({ conversationId: lead.conversationId, channel: lead.channel, text: Thanks${lead.name ? , ${lead.name.split(" ")[0]} : ""}. An agent will get back to you shortly. If you can, tell us when you would like to see the property., ...(lead.channel === "email" ? { subject: "We received your inquiry" } : {}), idempotencyKey: ack-${lead.conversationId}, }), }); if (res.status !== 202 && res.body?.code !== "conflict") throw new Error(res.body?.message); }

Passing channel keeps the reply on the channel the lead used. An email reply needs a subject. idempotencyKey makes the acknowledgment safe across webhook retries: a repeat answers 409 conflict with the original message in details.message and sends nothing.

If you pass a to that is not the person the conversation is with, the API answers 400 instead of opening another thread. The 202 reports how the message was routed:

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

reason is explicit because channel was given. estimatedCost is what the send took from your balance when it was accepted, which today applies to SMS only; on other channels it is null.

Putting it together:

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(?:REF|ID|MLS)[\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 does not always carry conversationId. The stored message always has it. const { body } = await zavu(/messages/${event.data.messageId}); if (!body?.message?.conversationId) return; // A follow-up from a known lead: ping the agent who owns it, update your CRM, and so on. }

5. Work the thread: in the dashboard and from your app

Your agents do not need your code to reply. Every lead is a thread in Conversations in the dashboard, whatever channel it came from. They read, answer and see the automatic acknowledgment in context.

If you have your own app, the same threads are in the API:

terminal
# Threads last handled from your WhatsApp number, most recent first curl "https://api.zavu.dev/v2/conversations?from=%2B13125551212&channel=whatsapp&limit=20" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Find a lead by phone or email curl "https://api.zavu.dev/v2/conversations?search=dana%40example.com" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Messages in one thread, newest first curl "https://api.zavu.dev/v2/conversations/js723987cyghwqxxaxcf590qd18axd95/messages" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" # Reset its unread count curl -X POST "https://api.zavu.dev/v2/conversations/js723987cyghwqxxaxcf590qd18axd95/read" \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"

from filters by the address a thread was last handled from, channel keeps threads that carried that channel, and both paginate with limit (up to 100) and cursor. search matches who the thread is with: a phone number, an email address, a WhatsApp username. It does not search message bodies, results come back by relevance rather than recency, and an empty search returns no items. Marking a thread read resets unreadCount in your inbox only; the lead sees no read receipt.

6. The WhatsApp 24-hour window

Your acknowledgment goes out inside WhatsApp's window, because the lead just wrote. The window matters the next day: if an agent follows up more than 24 hours after the lead's last message, WhatsApp refuses free-form text.

v2 does not check the window when it accepts the send. You get a 202, and then the message fails: message.failed arrives on your webhook and GET /v2/messages/{messageId} shows errorCode: "WHATSAPP_WINDOW_CLOSED". That is why the webhook above subscribes to message.failed. Outside the window, send a template Meta has approved (messageType: "template" with content.templateId). Email and Telegram have no such window.

Optional: an AI agent to pre-qualify

An agent can ask the questions your team asks every time: budget, area, buying or renting, when they can visit. Create one with POST /v2/agents (name, provider, model and systemPrompt are required) and connect it to an address from GET /v2/channels with POST /v2/agents/{agentId}/channels and {"address": "...", "type": "whatsapp"}. One-way SMS is refused with 409 channel_cannot_receive, since nobody can reply there. If an agent answers on an address, drop the automatic acknowledgment for it, or the lead gets two first replies. The AI chatbot for an online store tutorial walks through the agent itself.

Testing it, and what can go wrong

Test with a live key and a channel you can write to yourself: message your Telegram bot from your phone, or email the address you connected. A test-mode key always sends from the WhatsApp sandbox and does not consult conversationId, so it will not exercise the reply in step 4.

What you seeWhyWhat to do
401 on every deliveryYou verify a re-serialized body, or the wrong secretVerify the raw bytes; rotate with POST /v2/webhook/secret if the secret was lost
400 on PUT /v2/webhookURL is not HTTPS, or an event name is wrongCheck details.allowed
409 no_channel on a sendNothing connected can reach that recipientdetails.connect lists what to connect, with the exact call
403 ten_dlc_requiredSMS to a +1 number without an approved 10DLC campaignRegister, or answer on email, Telegram or WhatsApp
message.failed with WHATSAPP_WINDOW_CLOSEDFree-form WhatsApp more than 24 hours after the lead's last messageSend an approved template
429 daily_limit_exceededA channel's daily ceilingResets at 00:00 UTC; verification raises it

Every v2 error has the same shape, {code, message, details}; the full list is in the errors reference.

Going to production

  • Store processed event ids in your database, not in memory, so a restart does not reprocess retries.
  • Answer the webhook quickly. If Sheets or your CRM is slow, put the lead on a queue and return 200.
  • Sending limits and prices are v1's, whichever version sends. Read sending limits before a busy weekend of open houses.
  • For US texting, plan the 10DLC registration before you launch, not after the first refused send.
  • Consent. A lead who writes to you started the conversation. Marketing messages to people who did not are a different matter, on every channel.

The v2 reference is at docs.zavu.dev/api-v2/overview, and the webhook details at docs.zavu.dev/api-v2/webhook. If you are coming from v1, Migrate from v1 maps every change. For a broader picture of one inbox across channels, see what an omnichannel platform is.

Frequently asked questions

How do I capture real estate leads from WhatsApp, email and Telegram in one place?
Connect each channel to one Zavu project and subscribe a single webhook with PUT /v2/webhook to conversation.new and message.inbound. Every first message from a new lead arrives at the same URL, with the channel in the type field, the lead in from, and a conversationId you use to reply and to open the thread.
How fast can an automatic first reply go out to a new lead?
As fast as your webhook handler runs. When conversation.new arrives, send POST /v2/messages with the conversationId and the same channel, without a to field. The reply goes to the lead on the channel they used. Add an idempotencyKey so a retried delivery answers 409 conflict instead of sending twice.
Do I need 10DLC to text real estate leads in the US?
Yes, for SMS. Every SMS to a +1 number needs an approved 10DLC campaign, and without one the send is refused with 403 ten_dlc_required. Two-way SMS also needs a number of your own on a paid plan. Email and Telegram need no registration, and WhatsApp needs Meta's signup instead.
Can my agents reply to leads without using the API?
Yes. Every lead is a thread in Conversations in the Zavu dashboard, whatever channel it came from, and agents read and answer there. Your own app can work the same threads through GET /v2/conversations, GET /v2/conversations/{id}/messages and POST /v2/conversations/{id}/read.
What happens if an agent replies on WhatsApp after 24 hours?
The free-form message fails. Zavu accepts the send with 202, then the message moves to failed with errorCode WHATSAPP_WINDOW_CLOSED, visible in the message.failed webhook and GET /v2/messages/{messageId}. More than 24 hours after the lead's last message, send a template Meta has approved instead.
How do I store which property a lead asked about?
Put it in the contact's metadata. Read the conversation's contactId, then PATCH /v2/contacts/{contactId} with metadata such as propertyId and source. PATCH replaces metadata as a whole, so merge with the existing values first. The property id itself usually comes from prefilled wa.me text or the portal's email.

Need help? Contact us or join our Discord community for support.

Get started

Ready to get started?

Start building for free, or schedule a call to discuss your specific use case.

Real Estate Lead Response Automation | Zavu Blog