--- title: Automated Appointment Reminders with Confirm and Cancel Buttons, Built on Zavu API v2 description: Build automated appointment reminders with an appointment reminder API: send a WhatsApp template 24 hours ahead, take Confirm or Cancel replies on a webhook. date: 2026-10-19 author: Zavu Team locale: en source: https://www.zavu.dev/en/blog/recordatorios-de-citas-whatsapp tags: Tutorial, API v2, WhatsApp, Templates, Webhooks --- # Automated Appointment Reminders with Confirm and Cancel Buttons, Built on Zavu API v2 ## 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](https://docs.zavu.dev/api-v2/overview), 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](/en/blog/zavu-2-0): 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](/en/blog/what-is-a2p-10dlc) and [the registration requirements](/en/blog/10dlc-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 | |---------|---------------|------------------| | WhatsApp | Meta signup and an approved template | Nothing for this flow. | | Email | 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: ```bash 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. ```bash 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: ```bash 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`: ```bash 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: ```ts 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: ```bash 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: ```ts 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: ```ts 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: | 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](https://docs.zavu.dev/api-v2/errors). ## 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](/en/blog/whatsapp-service-messages-payment-method). 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](https://docs.zavu.dev/concepts/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](/en/blog/how-to-build-an-ai-agent).