TutorialAPI v2WhatsAppTemplatesWebhooks

Automated Appointment Reminders with Confirm and Cancel Buttons, Built on Zavu API v2

Build automated appointment reminders with an appointment reminder API: send a WhatsApp template 24 hours ahead, take Confirm or Cancel replies on a webhook.

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

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 idempotencyKey per appointment so a second run sends nothing.
  • The tap reaches your webhook as text. Verify the signature, then use replyToMessageId to 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:

ChannelWhat it takesWhat you give up
WhatsAppMeta signup and an approved templateNothing for this flow.
EmailA verified domainNo in-chat buttons; confirm with a link.
TelegramA bot token from @BotFatherThe 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:

terminal
curl 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.

terminal
curl -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 are UTILITY, MARKETING and AUTHENTICATION, and Zavu uses UTILITY when 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, type quick_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:

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

terminal
curl -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 set fallbackEnabled: true. That is what you want here: an SMS carries no buttons, and to a +1 number it would also need 10DLC.
  • One idempotencyKey per appointment. If your scheduler runs twice, or you retry after a timeout, the second call with the same key sends nothing. It answers 409 with code: "conflict" and the original in details.message.
  • Store message.id on 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:

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

terminal
curl -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. messageType is text and text is 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.replyToMessageId is 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=,v2=, where v2 is HMAC_SHA256(secret, "{t}.{body}") over the raw body. Verify it before you touch the schedule:

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

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

ResponseMeaningWhat to do
400 invalid_requestA field is missing or wrong.Read details.errors; often a to without its +.
400 no_whatsappYou submitted from an address with no WhatsApp.Use the whatsapp address from GET /v2/channels.
403 channel_inactiveThe channel is connected but switched off.Activate it in Channels.
403 ten_dlc_requiredAn SMS to a +1 number without 10DLC.Register 10DLC, or send on WhatsApp or email.
404 not_foundThe template or address is not in this project.Check TEMPLATE_ID and from.
409 conflictA repeated idempotencyKey.Already sent: use details.message.
409 no_channelNothing you have reaches that recipient.details.connect says what to connect.
429 daily_limit_exceededThe 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.

Frequently asked questions

Can I send appointment reminders by SMS to US numbers?
Only with an approved 10DLC campaign. SMS and one-way SMS to a +1 number are refused with 403 ten_dlc_required until the project has one, and registering needs an approved business verification. WhatsApp and email reach US recipients without 10DLC, which is why this tutorial uses WhatsApp for the buttons.
Do I need an approved template to send WhatsApp appointment reminders?
Yes. A reminder goes out when the patient has not written to you in the last 24 hours, and outside that window WhatsApp only accepts templates Meta has approved. Create the template with POST /v2/templates, submit it with POST /v2/templates/{id}/submit, and wait for its status to be approved.
How do I know which button the patient tapped?
From the inbound message text. When the patient taps a quick-reply button on the template, your webhook receives message.inbound with messageType text and text set to the button label, such as Confirm. content.replyToMessageId points at the reminder, so you know which appointment it answers.
What happens if my scheduler sends the same reminder twice?
Nothing is sent twice if you reuse the idempotencyKey. A second POST /v2/messages with a key already used answers 409 conflict, sends nothing, and returns the original message in details.message. Use one key per appointment, such as reminder-appt-4821, and treat that 409 as already sent.
Can I reply to the patient without another template after they confirm?
Yes. Tapping the button sends you a message, which opens WhatsApp's 24-hour customer service window. Inside it you can reply with free-form text in the same thread by passing conversationId to POST /v2/messages. Meta treats that reply as a service message.
How much does a WhatsApp appointment reminder cost?
Meta bills the utility template at the rate of the patient's country, directly to your WhatsApp Business Account, and Zavu adds no markup. WhatsApp messages count toward your Zavu plan's monthly allowance: 2,000 a month on Free, counting both directions, with no cap on paid plans.

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.

Appointment Reminder API with WhatsApp | Zavu Blog