TL;DR
- WhatsApp takes the most setup, Meta's signup and template approval, and is the only channel here where the patient answers with a button.
- The reminder is a utility template with two buttons, Confirm and Cancel.
- A scheduler sends it 24 hours ahead, with one
idempotencyKeyper appointment so a second run sends nothing. - The tap reaches your webhook as text. Verify the signature, then use
replyToMessageIdto find the right appointment. - The tap opens the 24-hour window, so you reply in the same thread with free-form text and
conversationId.
A no-show is an empty slot you already paid staff to cover. Dental offices, physical therapy clinics and salons solve it the same way: someone calls every patient the day before, or nobody does and the chair sits empty. Automated appointment reminders fix that when the patient can answer with one tap and your system hears about it without anyone reading a phone.
This tutorial builds that flow end to end on the Zavu API v2, used as an appointment reminder API from your own backend. At the end you will have:
- A WhatsApp utility template with two buttons: Confirm and Cancel.
- A Node scheduler that sends each reminder 24 hours ahead, with no duplicates even if it runs twice.
- A webhook that receives the button the patient tapped, verifies the signature, and updates the appointment.
- An automatic reply in the same thread: "You're all set."
New to v2? The short version is in the Zavu 2.0 announcement: there is no sender object. You send from an address with from, or reply inside a thread with conversationId.
What you need
- A Zavu account and an API key. Create the key in the dashboard under Build → Developers. The same key works on v1 and v2. Every call uses
Authorization: Bearer. - A WhatsApp channel. This is the channel with the most setup: it goes through Meta's signup (your WhatsApp Business Account), and Meta approves every template before you can send it. In exchange it is the only channel here where the patient answers with a button inside the chat.
- A Node 18+ backend (it ships
fetch) and a public HTTPS URL for the webhook.
A note on SMS in the US
SMS is the reflex for reminders in the US, so be clear about what it takes. SMS and one-way SMS to +1 numbers need an approved 10DLC campaign. Without one, the send is refused with 403 ten_dlc_required, and details.alternatives lists the channels that need none. Registering a brand and a campaign requires an approved business verification first. If you want to go that way, start with what A2P 10DLC is and the registration requirements. Even with 10DLC, an SMS has no buttons: the patient replies with a word you have to parse.
What reaches US patients without 10DLC:
| Channel | What it takes | What you give up |
|---|---|---|
| Meta signup and an approved template | Nothing for this flow. | |
| A verified domain | No in-chat buttons; confirm with a link. | |
| Telegram | A bot token from @BotFather | The patient must have started your bot first. |
The rest of this tutorial uses WhatsApp, because the buttons are the point.
1. Connect WhatsApp from Channels
WhatsApp, Instagram and Messenger connect through Meta, from the dashboard, not the API. Go to Build → Channels → Connect a channel, pick WhatsApp, and finish Meta's signup with the clinic's number. Then activate it on the same screen: a connected channel that is switched off refuses sends with 403 channel_inactive.
Confirm the address from the API. You need it to submit the template:
terminalcurl https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"
JSON{ "items": [ { "address": "+13125551212", "type": "whatsapp", "active": true, "v1SenderId": "kd7..." } ], "nextCursor": null }
+13125551212 is your WhatsApp address. In v2 a number is named the way the patient sees it.
2. Create the reminder template with buttons
A reminder goes out when the patient has not written to you in the last 24 hours. Outside that window WhatsApp only allows approved templates, so the reminder is a template.
terminalcurl -X POST https://api.zavu.dev/v2/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "appointment_reminder", "language": "en_US", "whatsappCategory": "UTILITY", "body": "Hi {{1}}, this is a reminder of your appointment at {{2}} on {{3}}. Can you confirm you will be there?", "footer": "Reply with a button", "buttons": [ { "type": "quick_reply", "text": "Confirm" }, { "type": "quick_reply", "text": "Cancel" } ] }'
What each field decides:
whatsappCategory:UTILITY, because it is a notice about something the patient already booked. The categories areUTILITY,MARKETINGandAUTHENTICATION, and Zavu usesUTILITYwhen you leave it out. Meta checks the category in review, so keep promotions out of the text.- Positional variables:
{{1}},{{2}},{{3}}. You fill them at send time with{"1": ..., "2": ..., "3": ...}. End the body with text, not a variable. buttons: up to 3, typequick_reply, labels up to 25 characters. Pick labels you will not change: your webhook reads them, as you will see below.
The answer is 201 with the template in draft. Keep its id and submit it, naming with from the WhatsApp number whose account receives it:
terminalcurl -X POST https://api.zavu.dev/v2/templates/TEMPLATE_ID/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+13125551212", "category": "UTILITY", "variableExamples": { "var1": "Emily", "var2": "Lakeview Dental", "var3": "Tuesday, Oct 21 at 10:30 AM" } }'
variableExamples are the sample values Meta sees in review; the keys are var1, var2, var3 for {{1}}, {{2}}, {{3}}. Leave them out and Zavu sends generic samples. A realistic one helps the reviewer understand the template.
The answer is 202 and the status moves to pending. If from exists but has no WhatsApp (your email address, say), you get 400 no_whatsapp, with details.types listing what that address does have.
To know when Meta decides, subscribe your webhook to template.status_changed (step 4), or poll GET /v2/templates/TEMPLATE_ID until status is approved. Do not schedule sends before that.
3. Send the reminder 24 hours ahead
With the template approved, a reminder is one POST /v2/messages:
terminalcurl -X POST https://api.zavu.dev/v2/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+13125551212", "to": "+17735550142", "channel": "whatsapp", "messageType": "template", "content": { "templateId": "TEMPLATE_ID", "templateVariables": { "1": "Emily", "2": "Lakeview Dental", "3": "Tuesday, Oct 21 at 10:30 AM" } }, "idempotencyKey": "reminder-appt-4821" }'
JSON{ "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "+17735550142", "from": "+13125551212", "channel": "whatsapp", "direction": "outbound", "messageType": "template", "status": "queued" }, "routing": { "channel": "whatsapp", "from": "+13125551212", "reason": "explicit", "estimatedCost": null } }
routing.estimatedCost is null on WhatsApp: it reports what the send takes from your Zavu balance, which today only SMS does. Three decisions in that call:
channel: "whatsapp". With an explicit channel, Zavu does not fall back to SMS when WhatsApp fails unless you setfallbackEnabled: true. That is what you want here: an SMS carries no buttons, and to a +1 number it would also need 10DLC.- One
idempotencyKeyper appointment. If your scheduler runs twice, or you retry after a timeout, the second call with the same key sends nothing. It answers409withcode: "conflict"and the original indetails.message. - Store
message.idon the appointment. That is how you match the reply to the right booking later.
The scheduler, in Node with TypeScript. It runs every 10 minutes and picks the appointments that start between 24 hours and 24 hours 10 minutes from now:
TypeScriptimport cron from "node-cron"; import { db } from "./db"; // your scheduling data const ZAVU = "https://api.zavu.dev/v2"; const headers = { Authorization: "Bearer " + process.env.ZAVUDEV_API_KEY, "Content-Type": "application/json", }; async function sendReminder(appt: Appointment) { const res = await fetch(ZAVU + "/messages", { method: "POST", headers, body: JSON.stringify({ from: process.env.WHATSAPP_FROM, // "+13125551212" to: appt.phone, // E.164, with "+" channel: "whatsapp", messageType: "template", content: { templateId: process.env.TEMPLATE_ID, templateVariables: { "1": appt.firstName, "2": appt.location, "3": formatWhen(appt.startsAt, appt.timeZone), }, }, idempotencyKey: "reminder-appt-" + appt.id, }), }); const json = await res.json(); if (res.status === 202) return json.message; if (res.status === 409 && json.code === "conflict") return json.details.message; // already sent throw new Error(json.code + ": " + json.message); } cron.schedule("/10 ", async () => { const from = Date.now() + 24 3600_000; const due = await db.appointmentsWithoutReminder(from, from + 10 * 60_000); for (const appt of due) { try { const message = await sendReminder(appt); await db.markReminded(appt.id, message.id, message.conversationId ?? null); } catch (err) { console.error("Reminder not sent", appt.id, err); } } });
Store start times in UTC and format them in the patient's time zone when you build the variable. A clinic in Chicago with patients who travel in from Indiana is already dealing with two zones.
message.conversationId can be empty in the 202: the first time you message a patient, the thread is created right after the send is accepted. Step 5 handles that.
4. Receive the Confirm or Cancel reply
v2 has one webhook per project. Create it with the three events this flow uses:
terminalcurl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://clinic.example.com/webhooks/zavu", "events": ["message.inbound", "message.failed", "template.status_changed"] }'
The 201 returns webhook.secret once. Put it in your environment; later reads do not return it, and POST /v2/webhook/secret rotates it.
How a button press arrives
Know this before writing the handler. When the patient taps Confirm on the template, your webhook receives a message.inbound like this:
JSON{ "id": "evt_1760900000000_k2j4h1", "event": "message.inbound", "timestamp": 1760900000000, "projectId": "jx7xyz789ghi012", "from": "+17735550142", "to": "+13125551212", "type": "whatsapp", "data": { "messageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "from": "+17735550142", "to": "+13125551212", "channel": "whatsapp", "messageType": "text", "text": "Confirm", "content": { "replyToMessageId": "jd7x2k3m4n5p6q7r8s9t0", "replyToProviderMessageId": "wamid.HBgM...", "replyToMessageType": "template" } } }
Two details your code has to respect:
- The button arrives as text.
messageTypeistextandtextis the button label. There is no separate button id, so the handler compares against the labels you put on the template. That is why you keep them stable. content.replyToMessageIdis the reminder id you stored in step 3. It finds the exact appointment, even when the patient has two that week. If it is missing, fall back to the next pending appointment for that phone (data.from).
The handler, with signature verification
Every delivery carries X-Zavu-Signature: t=, where v2 is HMAC_SHA256(secret, "{t}.{body}") over the raw body. Verify it before you touch the schedule:
TypeScriptimport express from "express"; import crypto from "node:crypto"; const app = express(); function validSignature(raw: string, header: string | undefined, secret: string) { if (!header) return false; const parts = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(parts.t); if (!t || !parts.v2) return false; if (Math.abs(Date.now() / 1000 - t) > 300) return false; // older than 5 minutes const expected = crypto.createHmac("sha256", secret).update(t + "." + raw).digest("hex"); return ( expected.length === parts.v2.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v2)) ); } app.post("/webhooks/zavu", express.raw({ type: "application/json" }), async (req, res) => { const raw = req.body.toString("utf8"); if (!validSignature(raw, req.header("X-Zavu-Signature"), process.env.ZAVU_WEBHOOK_SECRET!)) { return res.status(401).end(); } const event = JSON.parse(raw); res.status(200).end(); // acknowledge fast, process after if (await db.eventSeen(event.id)) return; if (event.event !== "message.inbound") return; const { text, from, content } = event.data; const answer = (text ?? "").trim().toLowerCase(); if (answer !== "confirm" && answer !== "cancel") return; // anything else goes to your inbox const appt = (content?.replyToMessageId && (await db.appointmentByReminder(content.replyToMessageId))) || (await db.nextPendingAppointment(from)); if (!appt) return; await db.updateAppointment(appt.id, answer === "confirm" ? "confirmed" : "cancelled"); await reply(appt, answer); });
A delivery that does not get a 2xx is retried, up to 5 attempts in total. Each event has its own id; use it so a retried delivery does not cancel twice.
5. Reply in the same thread
The tap is an inbound message, and it opens the 24-hour customer service window. Inside it you can reply with free-form text, no second template. In v2 you reply inside the thread with conversationId, and to is taken from the conversation:
TypeScriptasync function reply(appt: Appointment, answer: string) { let conversationId = appt.conversationId; if (!conversationId) { const r = await fetch(ZAVU + "/messages/" + appt.reminderMessageId, { headers }); conversationId = (await r.json()).message?.conversationId; } const text = answer === "confirm" ? "You're all set, " + appt.firstName + ". See you " + formatWhen(appt.startsAt, appt.timeZone) + "." : "Your appointment is cancelled. Reply here if you'd like another time."; await fetch(ZAVU + "/messages", { method: "POST", headers, body: JSON.stringify( conversationId ? { conversationId, channel: "whatsapp", text } : { from: process.env.WHATSAPP_FROM, to: appt.phone, channel: "whatsapp", text }, ), }); }
By the time the patient answers, the reminder has its thread, which is why the code reads conversationId from the message you stored, with GET /v2/messages/{id}, when the 202 did not carry it. Pass conversationId together with a to that is not that thread's patient and the API answers 400 instead of opening a second thread.
On a cancellation the thread stays open in Conversations in the dashboard, where the front desk can pick it up and offer another slot.
Testing it, and what can go wrong
Test with a live key and your own phone as the patient. A test-mode key always sends from Zavu's WhatsApp sandbox number and ignores from and conversationId, so it does not exercise your template.
v2 errors are flat, {code, message, details}, and code is what your code should branch on:
| Response | Meaning | What to do |
|---|---|---|
400 invalid_request | A field is missing or wrong. | Read details.errors; often a to without its +. |
400 no_whatsapp | You submitted from an address with no WhatsApp. | Use the whatsapp address from GET /v2/channels. |
403 channel_inactive | The channel is connected but switched off. | Activate it in Channels. |
403 ten_dlc_required | An SMS to a +1 number without 10DLC. | Register 10DLC, or send on WhatsApp or email. |
404 not_found | The template or address is not in this project. | Check TEMPLATE_ID and from. |
409 conflict | A repeated idempotencyKey. | Already sent: use details.message. |
409 no_channel | Nothing you have reaches that recipient. | details.connect says what to connect. |
429 daily_limit_exceeded | The channel's daily ceiling. | Resets at 00:00 UTC. |
A 202 means accepted, not delivered. If Meta refuses the message later, a message.failed arrives with an errorCode. Every code is in the API v2 errors reference.
Going to production
Meta approval. Every template is reviewed. If it is rejected, adjust the text and create a new one under a different name (or delete the old one with DELETE /v2/templates/{id}), then submit again. A utility template should be a notice about the appointment, with no offers in it.
The 24-hour window. The reminder goes as a template because the window is usually closed. The patient's tap opens it, and your reply goes as free-form text inside it.
Pricing. Meta bills utility templates at the rate of the patient's country, to your WhatsApp Business Account. Since October 1, 2026, replies inside the window are billable service messages too, with 1,000 free per number per month, and without a payment method on the account Meta stops delivering them from message 1,001. The details are in WhatsApp's 1,000 free service messages. Zavu adds no markup to Meta's rates.
Sending limits. WhatsApp, Telegram, Instagram and Messenger share a daily ceiling: 100 a day on an unverified account, 250 on Free once verified, and none on paid plans. On Free they also share 2,000 messages a month, and that allowance counts both directions: the reminder and the patient's "Confirm" are two. Run the numbers against your calendar before picking a plan; the full table is in sending limits.
Content. Send reminders only to patients who agreed to hear from you on WhatsApp, and keep the text to date, time and place. No procedures or diagnoses in the message.
From here, the natural next step is letting an agent handle rescheduling: see how to build an AI agent.