TL;DR
- One webhook receives every lead.
conversation.newfires 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
conversationIdand the lead's channel; anidempotencyKeykeeps 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/v2and you authenticate withAuthorization: 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:
terminalcurl 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:
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..."}'
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:
terminalcurl -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:
terminalcurl -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.newfires once, when someone writes to you for the first time. That is your new lead.message.inboundfires on every message a contact sends, including follow-ups.message.failedtells 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:
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; // 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() .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")}
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
TypeScriptimport 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:
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() }; }
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:
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; } // PATCH replaces metadata as a whole: merge with what is already there. 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!; }
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:
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: [,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:
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]}Thanks${lead.name ?: ""}. 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:
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(?: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(); if (!body?.message?.conversationId) return; // A follow-up from a known lead: ping the agent who owns it, update your CRM, and so on. }/messages/${event.data.messageId}
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 see | Why | What to do |
|---|---|---|
401 on every delivery | You verify a re-serialized body, or the wrong secret | Verify the raw bytes; rotate with POST /v2/webhook/secret if the secret was lost |
400 on PUT /v2/webhook | URL is not HTTPS, or an event name is wrong | Check details.allowed |
409 no_channel on a send | Nothing connected can reach that recipient | details.connect lists what to connect, with the exact call |
403 ten_dlc_required | SMS to a +1 number without an approved 10DLC campaign | Register, or answer on email, Telegram or WhatsApp |
message.failed with WHATSAPP_WINDOW_CLOSED | Free-form WhatsApp more than 24 hours after the lead's last message | Send an approved template |
429 daily_limit_exceeded | A channel's daily ceiling | Resets 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.